diff --git a/.claude/skills/onboarding/SKILL.md b/.claude/skills/onboarding/SKILL.md index 67810a25c8..a9df261ef6 100644 --- a/.claude/skills/onboarding/SKILL.md +++ b/.claude/skills/onboarding/SKILL.md @@ -19,7 +19,7 @@ Use TodoWrite to create a checklist tracking these phases: 1. Initial setup questions 2. Repository setup -3. Credential collection (Cloudflare, Vercel, Modal, Anthropic) +3. Credential collection (Cloudflare, Vercel if selected, Modal, Anthropic) 4. GitHub App creation (+ Google OAuth if enabled) 5. Slack App creation (if enabled) 6. Security secrets generation @@ -28,8 +28,9 @@ Use TodoWrite to create a checklist tracking these phases: 9. Post-deployment Slack setup (if enabled) 10. Post-deployment GitHub Bot setup (if enabled) 11. Web app deployment -12. Verification -13. CI/CD setup (optional) +12. Workspace Owner bootstrap +13. Verification +14. CI/CD setup (optional) ## Phase 1: Initial Questions @@ -44,14 +45,21 @@ Use AskUserQuestion to gather: 1. **Directory location** - Where to create the project (default: current directory or ~/workplace/open-inspect-{suffix}) 2. **GitHub account** - Which account/org hosts the private repo -3. **Deployment name** - A globally unique identifier for URLs (e.g., their GitHub username, company - name, or the random suffix generated above). Explain this creates URLs like - `open-inspect-{deployment_name}.vercel.app` and must be unique across all Vercel users. -4. **Slack integration** - Yes or No -5. **GitHub bot integration** - Yes or No (automated PR reviews and comment-triggered actions) -6. **Sign-in providers** - GitHub, Google, or both. At least one is required. -7. **Prerequisites confirmation** - Confirm they have accounts on Cloudflare, Vercel, Modal, - Anthropic +3. **Deployment name** - A unique identifier for deployment URLs (e.g., their GitHub username, + company name, or the random suffix generated above); Vercel project URLs must be globally unique. +4. **Web platform** - Vercel (default) or Cloudflare Workers. If Cloudflare, ask whether to use a + custom domain; if so, collect the hostname and Cloudflare zone ID. Derive one canonical web app + URL from this choice and the deployment name using Step 3 of `docs/GETTING_STARTED.md`; use it + for both OAuth callbacks and the GitHub App homepage. +5. **Slack integration** - Yes or No +6. **GitHub bot integration** - Yes or No (automated PR reviews and comment-triggered actions) +7. **Sign-in providers** - GitHub, Google, or both. At least one is required. +8. **Admission mode** - Restricted (collect the allowed GitHub usernames, exact verified emails, + email domains, and/or GitHub orgs) or explicitly open to any authenticated user (no allowlists). + Validate the chosen providers and admission mode against the compatibility table and allowlist + rules in Step 5 of `docs/GETTING_STARTED.md` before proceeding. +9. **Prerequisites confirmation** - Confirm they have Cloudflare, Modal, Anthropic, and GitHub + accounts, plus Vercel only if selected above. ## Phase 2: Repository Setup @@ -61,7 +69,7 @@ Execute these commands (substitute values from Phase 1): mkdir -p {directory_path} gh repo create {github_account}/open-inspect-{name} --private --description "Open-Inspect deployment" cd {directory_path} -git clone git@github.com:ColeMurray/open-inspect.git . +git clone git@github.com:ColeMurray/background-agents.git . git remote rename origin upstream git remote add origin git@github.com:{github_account}/open-inspect-{name}.git git push -u origin main @@ -82,7 +90,8 @@ Tell the user: `*.YOUR-SUBDOMAIN.workers.dev` - **API Token**: Create at https://dash.cloudflare.com/profile/api-tokens with template "Edit Cloudflare Workers" + permissions for Workers KV Storage (Edit), Workers R2 Storage (Edit), D1 - (Edit) + (Edit), Queues (Edit). For a Cloudflare web app with a custom domain, also grant zone-level + Workers Routes (Edit). ### R2 Bucket @@ -90,13 +99,13 @@ Check wrangler login status, then create bucket: ```bash wrangler whoami -wrangler r2 bucket create open-inspect-{name}-tf-state +wrangler r2 bucket create open-inspect-terraform-state ``` Tell user to create R2 API Token at R2 → Overview → Manage R2 API Tokens with "Object Read & Write" permission. -### Vercel +### Vercel (Only If Selected In Phase 1) - **API Token**: https://vercel.com/account/tokens - **Team/Account ID**: Settings → "Your ID" (even personal accounts have one, usually starts with @@ -126,25 +135,25 @@ user selected GitHub: 1. Go to https://github.com/settings/apps → "New GitHub App" 2. **Name**: `Open-Inspect-{YourName}` (globally unique) -3. **Homepage URL**: The deployed web app URL for the selected platform: - - Vercel: `https://open-inspect-{deployment_name}.vercel.app` - - Cloudflare workers.dev: `https://open-inspect-web-{deployment_name}.{subdomain}.workers.dev` - - Cloudflare custom domain: `https://{your-custom-domain}` +3. **Homepage URL**: The canonical web app URL from Phase 1 4. **Webhook**: Uncheck "Active" 5. If GitHub sign-in is selected, set the **Callback URL** (under "Identifying and authorizing - users"): `{deployed-web-app-url}/api/auth/callback/github` + users"): `{canonical-web-app-url}/api/auth/callback/github` - **CRITICAL**: The origin must exactly match the Homepage URL selected above. 6. **Repository permissions**: Contents (Read & Write), Pull requests (Read & Write), Metadata - (Read-only), and Issues (Read & Write) only if the GitHub bot is enabled. Pull requests - permission also authorizes creating and applying labels to session-created pull requests; - labeling does not require Issues permission. -7. If GitHub sign-in uses email/domain admission, set **Account permissions**: Email addresses - (Read-only) -8. Create app, note **App ID** -9. If GitHub sign-in is selected, generate a **Client Secret** and note the **Client ID** and - **Client Secret**. Otherwise leave both Terraform values empty. -10. Generate **Private Key** (downloads .pem file) -11. Install app on account, note **Installation ID** from URL + (Read-only). If the GitHub bot is enabled, also grant Actions (Read-only), Checks (Read-only), + and Issues (Read & Write). Pull requests permission also authorizes creating and applying labels + to session-created pull requests; labeling does not require Issues permission. +7. If GitHub organizations are allowlisted, set **Organization permissions**: Members (Read-only). + Existing Apps need the permission change approved on their installations. +8. If GitHub sign-in is enabled, set **Account permissions**: Email addresses (Read-only). Every + GitHub sign-in requires a verified email, regardless of admission mode. Existing Apps need the + permission change approved on their installations. +9. Create app, note **App ID** +10. If GitHub sign-in is selected, generate a **Client Secret** and note the **Client ID** and + **Client Secret**. Otherwise leave both Terraform values empty. +11. Generate **Private Key** (downloads .pem file) +12. Install app on account, note **Installation ID** from URL After receiving the .pem path, convert to PKCS#8: @@ -162,10 +171,8 @@ Guide user: 1. https://console.cloud.google.com/apis/credentials → "Create Credentials" → "OAuth client ID" 2. **Application type**: Web application -3. **Authorized redirect URI**: - `https://open-inspect-{deployment_name}.vercel.app/api/auth/callback/google` (or your - `*.workers.dev` web URL if `web_platform = "cloudflare"`) - - **CRITICAL**: Must match deployed web URL exactly! +3. **Authorized redirect URI**: `{canonical-web-app-url}/api/auth/callback/google` + - **CRITICAL**: Must match the web URL from Phase 1 exactly! 4. OAuth consent screen: request only `openid`, `email`, `profile` scopes (non-sensitive — no Google verification review required) 5. Note **Client ID** and **Client Secret** @@ -174,8 +181,6 @@ Then in `terraform.tfvars`: - Set `google_client_id` and `google_client_secret` (both required together; leave both empty to disable) -- Add at least one entry to `allowed_emails` (exact addresses, e.g. `pm@gmail.com`) or - `allowed_email_domains`. Prefer `allowed_emails` for shared domains like gmail.com. - If Google is the only sign-in provider, leave `github_client_id` and `github_client_secret` empty. Keep the GitHub App ID, private key, and installation ID configured for repository access. @@ -204,7 +209,6 @@ to Slack. Reinstall the app whenever either scope is added to an existing instal ```bash echo "token_encryption_key: $(openssl rand -base64 32)" echo "repo_secrets_encryption_key: $(openssl rand -base64 32)" -echo "internal_callback_secret: $(openssl rand -base64 32)" echo "nextauth_secret: $(openssl rand -base64 32)" echo "modal_api_secret: $(openssl rand -hex 32)" echo "github_webhook_secret: $(openssl rand -hex 32)" # Only if GitHub bot enabled @@ -212,12 +216,12 @@ echo "github_webhook_secret: $(openssl rand -hex 32)" # Only if GitHub bot enab ## Phase 7: Terraform Configuration -Create `terraform/environments/production/backend.tfvars`: +Create `terraform/environments/production/backend.tfvars`. The bucket is already fixed as +`open-inspect-terraform-state` in `backend.tf`; do not override it here: ```hcl access_key = "{r2_access_key}" secret_key = "{r2_secret_key}" -bucket = "open-inspect-{name}-tf-state" endpoints = { s3 = "https://{cloudflare_account_id}.r2.cloudflarestorage.com" } @@ -226,10 +230,24 @@ endpoints = { Create `terraform/environments/production/terraform.tfvars` with all collected values. Set: ```hcl +web_platform = "{vercel_or_cloudflare_from_phase_1}" enable_durable_object_bindings = false enable_service_bindings = false ``` +If Phase 1 selected a Cloudflare custom domain, also set `cloudflare_custom_domain` and +`cloudflare_zone_id` to the collected hostname and zone ID. Leave both unset otherwise. If Vercel +was not selected, leave `vercel_api_token` and `vercel_team_id` unset (not empty strings). + +Set `enable_slack_bot = false` if Slack was declined in Phase 1; Terraform defaults it to `true`. If +Slack was selected, set `enable_slack_bot = true`, `slack_bot_token`, and `slack_signing_secret` to +the collected values. + +Write the restricted-mode comma-separated Phase 1 answers to `allowed_users`, +`allowed_email_domains`, `allowed_emails`, and `allowed_github_orgs`; leave unused inputs empty and +set `unsafe_allow_all_users = false`. For explicitly open mode, leave all four lists empty and set +`unsafe_allow_all_users = true`. Use Step 5 of `docs/GETTING_STARTED.md` for the admission contract. + If GitHub bot is enabled, also set: ```hcl @@ -315,8 +333,8 @@ After Terraform deployment, guide user: 3. **Webhook URL**: `https://open-inspect-github-bot-{deployment_name}.{subdomain}.workers.dev/webhooks/github` 4. **Webhook secret**: Enter the `github_webhook_secret` value -5. Under **Subscribe to events**, check: **Pull requests**, **Issue comments**, **Pull request - review comments** +5. Under **Subscribe to events**, check: **Pull requests**, **Issues**, **Issue comments**, **Pull + request reviews**, **Pull request review comments**, **Check suites**, **Workflow runs** 6. Save changes ### Find Bot Username @@ -332,30 +350,49 @@ terraform.tfvars. ## Phase 11: Web App Deployment +For the Cloudflare choice from Phase 1, Terraform deploys the web app; no manual step is needed. For +Vercel, deploy from the repository root: + ```bash npx vercel link --project open-inspect-{deployment_name} npx vercel --prod ``` -## Phase 12: Verification +## Phase 12: Bootstrap the Workspace Owner + +After the web app is deployed, guide the intended Owner through Step 9 of `docs/GETTING_STARTED.md`. +Obtain their canonical user ID after sign-in; run `npm run rbac:bootstrap-owner` from the repository +root as a dry run, execute only if ready, and verify by rerunning the dry run. Follow Step 9 for +commands and refusal/no-op handling. + +## Phase 13: Verification + +From the repository root: ```bash curl https://open-inspect-control-plane-{deployment_name}.{subdomain}.workers.dev/health -curl https://{workspace}--open-inspect-api-health.modal.run -curl -I "$(terraform output -raw web_app_url)" +curl "$(terraform -chdir=terraform/environments/production output -raw modal_health_url)" +curl -I "$(terraform -chdir=terraform/environments/production output -raw web_app_url)" ``` Present a deployment summary table. Instruct the user to test: visit the web app, sign in with each configured provider, create a session, and send a prompt. -## Phase 13: CI/CD Setup (Optional) +## Phase 14: CI/CD Setup (Optional) -Ask if user wants GitHub Actions CI/CD. If yes, use `gh secret set` for all required secrets. +Ask if user wants GitHub Actions CI/CD. If yes, follow the "Set Up CI/CD (Optional)" section of +`docs/GETTING_STARTED.md` for the required variables and secrets, including the Phase 1 admission +mode and allowlists. CI uses the same `open-inspect-terraform-state` R2 bucket as local Terraform. ## Error Handling - **"redirect_uri is not associated"**: Callback URL mismatch - update GitHub App settings - **Durable Object errors**: Must follow two-phase deployment +- **"At least one access control allowlist must be configured"**: Set an appropriate `allowed_*` + Terraform value or the explicit Phase 1 open-access choice; check Step 5 of + `docs/GETTING_STARTED.md` and the corresponding CI variables/secrets. +- **Queue creation or binding permission errors**: Grant Account | Queues | Edit to the Cloudflare + API token and rerun `terraform apply`. - **Slack bot not responding**: Check Event Subscriptions URL verified, bot invited to channel, reinstall if scopes changed - **GitHub bot not responding**: Check webhook URL, secret, `enable_github_bot = true`, and diff --git a/.env.example b/.env.example index 8308c2f85e..ad56555960 100644 --- a/.env.example +++ b/.env.example @@ -40,19 +40,20 @@ SHUTDOWN_TIMEOUT_MS=30000 # Object storage for media (Cloudflare: the MEDIA_BUCKET R2 binding) # --------------------------------------------------------------------------- -# Any S3-compatible bucket. The compose stack points these at its MinIO service. +# Any S3-compatible bucket. The compose stack points these at its object store. OBJECT_STORE_BUCKET=media OBJECT_STORE_REGION=us-east-1 -# Leave empty for AWS S3. MinIO and other S3-compatible services need it. -OBJECT_STORE_ENDPOINT=http://minio:9000 -# Plain-http endpoints are refused unless this is "true" (local MinIO only). +# Leave empty for AWS S3. Other S3-compatible services need it. +OBJECT_STORE_ENDPOINT=http://object-store:9000 +# Plain-http endpoints are refused unless this is "true" (local object store only). OBJECT_STORE_ALLOW_HTTP=true -# "true" for MinIO (bucket in the path, not the hostname). +# "true" for the local object store (bucket in the path, not the hostname). OBJECT_STORE_FORCE_PATH_STYLE=true # Credentials through the AWS SDK's default chain. Leave both empty on EC2 to -# use the instance role. For the compose stack they are the MinIO root user -# and password (MINIO_ROOT_USER / MINIO_ROOT_PASSWORD below). -AWS_ACCESS_KEY_ID=minioadmin +# use the instance role. For the compose stack they are the object store's +# root user and password (OBJECT_STORE_ROOT_USER / OBJECT_STORE_ROOT_PASSWORD +# below). +AWS_ACCESS_KEY_ID=objectstoreadmin AWS_SECRET_ACCESS_KEY= # Only with temporary (STS) credentials; empty for static keys or a role. AWS_SESSION_TOKEN= @@ -151,12 +152,13 @@ GITLAB_ACCESS_TOKEN= GITLAB_NAMESPACE= # --------------------------------------------------------------------------- -# Sandbox provider. One of "modal" (default), "daytona", "vercel", -# "opencomputer", "e2b"; only that provider's block needs values. +# Sandbox provider. One of "modal" (default), "modal-vm", "daytona", "vercel", +# "e2b", "boat"; only that provider's block needs values. # Cloudflare: var.sandbox_provider # --------------------------------------------------------------------------- SANDBOX_PROVIDER=modal +# Set modal-vm for deployment-wide Docker-capable VMs; see docs/MODAL_DOCKER.md. # Modal. The API secret is shared with the Modal deployment for HMAC-signed # endpoint calls; the workspace and environment build the endpoint URLs. @@ -177,12 +179,22 @@ MODAL_TOKEN_ID= MODAL_TOKEN_SECRET= # Daytona. Cloudflare: secret var.daytona_api_key; var.daytona_api_url, -# var.daytona_base_snapshot, var.daytona_target. The two interval settings are -# not set by Terraform. +# var.daytona_base_snapshot, var.daytona_target, var.daytona_toolbox_api_url, +# var.daytona_prebuilds_enabled. The two interval settings are not set by +# Terraform. +# +# DAYTONA_TOOLBOX_API_URL overrides the per-sandbox toolbox proxy; leave it +# empty on a deployment whose sandboxes report their own. +# +# DAYTONA_PREBUILDS_ENABLED ("true"/"1") admits new Daytona image builds and +# lets fresh sessions boot from one. Default off: callbacks, finalization, +# status and cleanup keep working while it is, so closing it is the rollback. DAYTONA_API_KEY= DAYTONA_API_URL= DAYTONA_BASE_SNAPSHOT= DAYTONA_TARGET= +DAYTONA_TOOLBOX_API_URL= +DAYTONA_PREBUILDS_ENABLED= DAYTONA_AUTO_STOP_INTERVAL_MINUTES= DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES= @@ -201,12 +213,6 @@ VERCEL_RUNTIME= VERCEL_SANDBOX_API_BASE_URL= VERCEL_SNAPSHOT_EXPIRATION_MS= -# OpenComputer. Cloudflare: secret var.opencomputer_api_key; -# var.opencomputer_api_url, var.opencomputer_template -OPENCOMPUTER_API_KEY= -OPENCOMPUTER_API_URL= -OPENCOMPUTER_TEMPLATE= - # E2B. Cloudflare: secret var.e2b_api_key; var.e2b_api_url, # var.e2b_template_id, var.e2b_sandbox_timeout_seconds, var.e2b_auto_pause E2B_API_KEY= @@ -215,31 +221,50 @@ E2B_TEMPLATE_ID= E2B_SANDBOX_TIMEOUT_SECONDS= E2B_AUTO_PAUSE= -# --------------------------------------------------------------------------- -# Models -# --------------------------------------------------------------------------- - -# Optional Anthropic API key for Claude models. It reaches sandboxes on the Modal -# and OpenComputer providers only; every provider also reads ANTHROPIC_API_KEY -# from the web app's secret store, which overrides this one. Still required when -# the Slack or Linear classifier runs on an Anthropic model. -# Cloudflare: secret, var.anthropic_api_key -ANTHROPIC_API_KEY= +# Boat. Cloudflare: secret var.boat_api_key; var.boat_api_url, +# var.boat_base_snapshot (the saved snapshot the boat-infra module builds), +# var.boat_org, var.boat_sandbox_type. +# +# BOAT_SANDBOX_TYPE is the machine size (small, default, large, xlarge) for +# sessions that request no CPU or memory; a request picks the smallest size +# that fits it. When sandboxes bill an organization (BOAT_ORG, or the account's +# active wallet), set the organization's default visibility to private; the +# provider unshares any sandbox that still starts shared. +BOAT_API_KEY= +BOAT_API_URL= +BOAT_BASE_SNAPSHOT= +BOAT_ORG= +BOAT_SANDBOX_TYPE= # --------------------------------------------------------------------------- # Sandbox lifecycle (Cloudflare: var) # --------------------------------------------------------------------------- -# Idle time before a sandbox is stopped, in ms (default 600000). Cloudflare: var.sandbox_inactivity_timeout_ms +# A connected session-page WebSocket extends idle timers; direct terminal, +# VS Code, or VNC connections alone do not. +# Regular idle timeout, in ms (default 600000), for user, slack-bot, and linear-bot sessions. +# Cloudflare: var.sandbox_inactivity_timeout_ms SANDBOX_INACTIVITY_TIMEOUT_MS= -# Longest a turn may run before it is failed, in ms. A session without its -# own sandbox timeout setting falls back to the sandbox timeout (7200000); -# the automation recovery sweep falls back to 5400000. Cloudflare: not set -# by Terraform. +# Idle timeout for automation, github-bot, and agent sessions, in ms (default 120000, +# minimum 60000; lower values are rejected). Effective timeout is min(this, regular). +# Set at or above SANDBOX_INACTIVITY_TIMEOUT_MS to disable the shorter timeout. +# Cloudflare: var.sandbox_unattended_inactivity_timeout_ms +SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS= +# Longest a sandbox whose bridge has connected may keep booting (clone, +# setup.sh, start.sh, agent start) before it is failed and the prompt it was +# for fails, in ms (default 1800000). Cloudflare: var.sandbox_boot_timeout_ms +SANDBOX_BOOT_TIMEOUT_MS= +# Longest a turn may run before it is failed, in ms; also the budget the +# automation recovery sweep holds a run to. A session without its own sandbox +# timeout setting falls back to the sandbox timeout (7200000). Cloudflare: not +# set by Terraform. EXECUTION_TIMEOUT_MS= # "enforce" (default) fails spawn and build on oversized secret payloads; # "warn" only logs. Cloudflare: not set by Terraform. SECRETS_CAP_ENFORCEMENT= +# Session team enforcement: off, shadow, or on. Unset defaults to shadow. +# Private visibility applies in every mode. Cloudflare: var.teams_enforcement. +TEAMS_ENFORCEMENT= # --------------------------------------------------------------------------- # Logging @@ -255,22 +280,23 @@ LOG_LEVEL=info # Host interface the app's port is published on. Loopback keeps the plaintext # listener local; Caddy (the "tls" profile) reaches the app over the compose # network. Use 0.0.0.0 only where a firewall or security group fronts the -# host, such as behind the AWS load balancer. MinIO's API and console are +# host, such as behind the AWS load balancer. The object store's S3 port is # published on loopback only. APP_BIND_ADDRESS=127.0.0.1 -# MinIO root credentials. The password is required and has no default: +# Root credentials for the compose stack's object store. The password is +# required and has no default: # openssl rand -hex 16 # The AWS_* and LITESTREAM_* credentials are these same values when the -# stack's MinIO is the object store. -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD= +# stack's own object store is in use. +OBJECT_STORE_ROOT_USER=objectstoreadmin +OBJECT_STORE_ROOT_PASSWORD= # Where Litestream replicates DATA_DIR/global.db, read by the sidecar and by # the app image's restore-on-empty entrypoint. The compose stack requires a # bucket name; only the image run on its own treats an empty value as "no # replica". LITESTREAM_ENDPOINT is empty for AWS S3. LITESTREAM_BUCKET=backups -LITESTREAM_ENDPOINT=http://minio:9000 -LITESTREAM_ACCESS_KEY_ID=minioadmin +LITESTREAM_ENDPOINT=http://object-store:9000 +LITESTREAM_ACCESS_KEY_ID=objectstoreadmin LITESTREAM_SECRET_ACCESS_KEY= # Hostname Caddy obtains a certificate for under the "tls" profile. CADDY_DOMAIN= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..59e03fa461 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,84 @@ +version: 2 + +# Scheduled version updates. Security updates are enabled separately in the +# repository settings and still cover every ecosystem, including the ones +# excluded here. +# +# Terraform providers and Docker base images are intentionally not scheduled: +# Dependabot PRs cannot run `terraform plan` (no production secrets) and merges +# to main auto-apply. Bump them manually from a regular branch. + +updates: + # npm — the root lockfile covers every packages/* workspace. The + # sandbox-images/locks/* npm closures are pinned via toolchain.json and + # intentionally excluded. + - package-ecosystem: npm + directory: / + schedule: + # Roughly every two weeks; Dependabot has no biweekly interval. + interval: cron + cronjob: "0 9 1,15 * *" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + groups: + # One PR for all minor/patch bumps; majors still get their own. + npm-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: cron + cronjob: "0 9 1,15 * *" + cooldown: + default-days: 7 + groups: + actions: + patterns: + - "*" + + # Python. sandbox-runtime and sandbox-images/locks/python-tools are excluded: + # their uv.lock feeds hash-checked exports (locks/runtime.txt, + # locks/python-tools.txt) that `sandbox:images lock` must regenerate, and + # python-tools' pyproject.toml is generated from toolchain.json. e2b-infra and + # daytona-infra are excluded until PR CI installs and tests their locked SDKs; + # Terraform consumes those lockfiles during apply. + - package-ecosystem: uv + directories: + - /packages/modal-infra + - /packages/sandbox-images + # Scanning /packages/sandbox-images also finds locks/*.txt, and Dependabot + # edits those exports as plain requirements files without their uv.lock. + # exclude-paths covers version updates only: a security update can still + # edit them, which `sandbox:images lock --check` fails in CI. Fix such PRs + # by upgrading the source uv.lock and running `sandbox:images lock`. + exclude-paths: + - "locks/**" + ignore: + # Newer Modal releases remove the endpoint introspection modal-infra + # tests use (Function.get_raw_f, .spec, .get_build_def). Bump it manually. + # update-types applies to version updates only, so Modal security + # updates still open PRs. + - dependency-name: modal + update-types: + - version-update:semver-major + - version-update:semver-minor + - version-update:semver-patch + schedule: + interval: cron + cronjob: "0 9 1,15 * *" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + groups: + python-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/.github/workflows/aws-stack.yml b/.github/workflows/aws-stack.yml new file mode 100644 index 0000000000..94c1471651 --- /dev/null +++ b/.github/workflows/aws-stack.yml @@ -0,0 +1,97 @@ +# Everything that parses the AWS deployment without deploying it. +# +# Split out of terraform.yml because its path filter is the union of every +# service's -- a change to the web app was running the whole AWS suite. These +# checks need no credentials and no AWS account (mocked providers, a stubbed +# CLI), so they run for everyone; they just have no business running for +# everything. +# +# `terraform fmt` and the production environment's own tests stay in +# terraform.yml, which a change under `terraform/**` triggers anyway. +name: AWS stack + +on: + push: + branches: [main] + paths: + - "terraform/modules/aws-control-plane/**" + - "terraform/environments/aws-staging/**" + - "terraform/environments/aws-production/**" + # Uploaded verbatim by the module, so these are what the instance runs. + - "docker-compose.yml" + - "docker-compose.aws.yml" + - "packages/control-plane/docker/**" + - "scripts/check-aws-stack.sh" + - "scripts/deploy-aws.sh" + - ".github/workflows/aws-stack.yml" + pull_request: + branches: [main] + # Duplicated rather than anchored: the Actions parser does not reliably + # resolve YAML aliases, and every other workflow here repeats its list. + paths: + - "terraform/modules/aws-control-plane/**" + - "terraform/environments/aws-staging/**" + - "terraform/environments/aws-production/**" + # Uploaded verbatim by the module, so these are what the instance runs. + - "docker-compose.yml" + - "docker-compose.aws.yml" + - "packages/control-plane/docker/**" + - "scripts/check-aws-stack.sh" + - "scripts/deploy-aws.sh" + - ".github/workflows/aws-stack.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: aws-stack-${{ github.ref }} + cancel-in-progress: true + +env: + TF_VERSION: "1.14.8" + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # Nothing here runs an authenticated git command, so there is no + # reason to leave a token behind for the pull request's own code. + persist-credentials: false + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: ${{ env.TF_VERSION }} + + # `terraform validate` reads HCL only: it never renders the instance + # bootstrap, and the compose smoke boots a different overlay. This is the + # only thing that parses either. + - name: AWS stack static checks + run: scripts/check-aws-stack.sh + + # Deploys from CI move an image; `terraform apply` for these environments + # stays a human's, so this is the only thing that parses them. The module + # is shared, so a break here is a break there. + - name: Terraform Init and Validate (AWS environments) + run: | + for environment in aws-staging aws-production; do + echo "::group::$environment" + terraform -chdir="terraform/environments/$environment" init -backend=false -no-color + terraform -chdir="terraform/environments/$environment" validate -no-color + echo "::endgroup::" + done + + # `validate` does not evaluate locals, so these runs are the only thing + # that plans the module at all. Mocked providers, no credentials. The + # directory is unfiltered so a new tests/*.tftest.hcl file is gated the + # moment it lands. + - name: Terraform Test (AWS control-plane module) + run: | + terraform -chdir=terraform/modules/aws-control-plane init -backend=false -no-color + terraform -chdir=terraform/modules/aws-control-plane test -no-color diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index aca9aef540..06b72cbadd 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -5,9 +5,11 @@ on: branches: [main] paths: - ".github/workflows/ci-python.yml" + - ".nvmrc" - "packages/control-plane/src/image-builds/timeouts.ts" - "packages/daytona-infra/**" - "packages/e2b-infra/**" + - "packages/boat-infra/**" - "packages/modal-infra/**" - "packages/sandbox-runtime/**" - "packages/sandbox-images/**" @@ -23,9 +25,11 @@ on: branches: [main] paths: - ".github/workflows/ci-python.yml" + - ".nvmrc" - "packages/control-plane/src/image-builds/timeouts.ts" - "packages/daytona-infra/**" - "packages/e2b-infra/**" + - "packages/boat-infra/**" - "packages/modal-infra/**" - "packages/sandbox-runtime/**" - "packages/sandbox-images/**" @@ -55,12 +59,12 @@ jobs: working-directory: packages/sandbox-runtime steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" cache: "pip" @@ -82,12 +86,12 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" cache: "pip" @@ -99,10 +103,10 @@ jobs: pip install -e "packages/modal-infra[dev]" - name: Run Ruff linter - run: ruff check packages/modal-infra/ packages/e2b-infra/ packages/daytona-infra/ packages/sandbox-images/ + run: ruff check packages/modal-infra/ packages/e2b-infra/ packages/daytona-infra/ packages/boat-infra/ packages/sandbox-images/ - name: Run Ruff formatter check - run: ruff format --check packages/modal-infra/ packages/e2b-infra/ packages/daytona-infra/ packages/sandbox-images/ + run: ruff format --check packages/modal-infra/ packages/e2b-infra/ packages/daytona-infra/ packages/boat-infra/ packages/sandbox-images/ typecheck-python: name: TypeCheck (Python) @@ -113,12 +117,12 @@ jobs: working-directory: packages/modal-infra steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" cache: "pip" @@ -143,12 +147,12 @@ jobs: working-directory: packages/sandbox-runtime steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" cache: "pip" @@ -172,20 +176,20 @@ jobs: working-directory: packages/sandbox-runtime steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" cache: "pip" - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc - name: Install dependencies run: | @@ -193,7 +197,8 @@ jobs: pip install -e ".[dev]" - name: Run tests - run: pytest tests/ -v + # Dump stacks and exit stalled tests before the job timeout prevents log upload. + run: pytest tests/ -v --durations=20 -o faulthandler_timeout=60 -o faulthandler_exit_on_timeout=true - name: Run Node.js tests run: node --test tests/*.test.mjs @@ -208,26 +213,23 @@ jobs: working-directory: packages/modal-infra steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.12" cache: "pip" - - name: Setup frozen image lock checker - uses: astral-sh/setup-uv@v6 + - name: Setup uv + uses: astral-sh/setup-uv@v7 with: version: "0.9.7" - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ../sandbox-runtime - pip install -e ".[dev]" + run: uv sync --frozen --extra dev - name: Run tests - run: pytest tests/ -v + run: uv run --frozen --extra dev pytest tests/ -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6fefc6157..f654f4d997 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: paths: - ".env.example" - ".github/workflows/ci.yml" + - ".github/workflows/terraform.yml" + - ".nvmrc" - ".prettierignore" - ".prettierrc" - "eslint.config.js" @@ -15,7 +17,6 @@ on: - "packages/control-plane/**" - "packages/github-bot/**" - "packages/linear-bot/**" - - "packages/opencomputer-infra/**" - "packages/sandbox-runtime/**" - "packages/sandbox-images/**" - "packages/vercel-infra/**" @@ -29,16 +30,23 @@ on: - "terraform/environments/production/**" - "vitest.workspace.ts" - "!**/*.md" + # Keep README translation checks active for documentation-only changes. + - "README.md" + - "README.zh-CN.md" - "packages/sandbox-runtime/src/**" - "packages/sandbox-images/**" # Re-included after the exclusion above, which later patterns override: a # unit test holds this document's quick start to the required variables. - "docs/CONTROL_PLANE_CONTAINER.md" + # Re-include docs content and its package README after the Markdown exclusion. + - "packages/docs/**" pull_request: branches: [main] paths: - ".env.example" - ".github/workflows/ci.yml" + - ".github/workflows/terraform.yml" + - ".nvmrc" - ".prettierignore" - ".prettierrc" - "eslint.config.js" @@ -48,7 +56,6 @@ on: - "packages/control-plane/**" - "packages/github-bot/**" - "packages/linear-bot/**" - - "packages/opencomputer-infra/**" - "packages/sandbox-runtime/**" - "packages/sandbox-images/**" - "packages/vercel-infra/**" @@ -62,11 +69,16 @@ on: - "terraform/environments/production/**" - "vitest.workspace.ts" - "!**/*.md" + # Keep README translation checks active for documentation-only changes. + - "README.md" + - "README.zh-CN.md" - "packages/sandbox-runtime/src/**" - "packages/sandbox-images/**" # Re-included after the exclusion above, which later patterns override: a # unit test holds this document's quick start to the required variables. - "docs/CONTROL_PLANE_CONTAINER.md" + # Re-include docs content and its package README after the Markdown exclusion. + - "packages/docs/**" permissions: contents: read @@ -86,14 +98,16 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - # Minimum supported release: node:sqlite is available without an - # additional flag and --experimental-transform-types is present. - node-version: "22.13.0" + # Minimum supported release, matching `engines` in package.json. + # Every other job runs the version in .nvmrc. + node-version: "24.0.0" cache: "npm" - name: Install dependencies @@ -111,6 +125,9 @@ jobs: - name: Test SQL portability check run: npm run test:lint-sql-portability + - name: Test AWS deploy rollback + run: npm run test:deploy-aws + - name: Test Owner bootstrap CLI run: npm run test:rbac-bootstrap-owner @@ -120,6 +137,15 @@ jobs: - name: Test Cloudflare logs CLI run: npm run test:cf-logs-cli + - name: Test Terraform workflow contract + run: npm run test:terraform-workflow-contract + + - name: Test Node version workflow contract + run: npm run test:node-version-workflow-contract + + - name: Check README translations + run: npm run test:readme-translations + - name: Check Prettier formatting run: npm run format:check @@ -132,12 +158,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -155,12 +183,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -172,6 +202,31 @@ jobs: - name: Build web package run: npm run build -w @open-inspect/web + - name: Verify Mermaid browser isolation + run: npm run test:mermaid-browser -w @open-inspect/web + + build-docs: + name: Build (Docs) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build docs package + run: npm run build -w @open-inspect/docs + test-cp-unit: name: Test (control-plane unit) runs-on: ubuntu-latest @@ -180,12 +235,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -213,12 +270,14 @@ jobs: shard: ["1/2", "2/2"] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -238,12 +297,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -255,18 +316,42 @@ jobs: - name: Run web tests run: npm test -w @open-inspect/web + test-docs: + name: Test (docs) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run docs tests + run: npm test -w @open-inspect/docs + test-bots: name: Test (bots) runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies diff --git a/.github/workflows/compose-smoke.yml b/.github/workflows/compose-smoke.yml index e669537073..90cba781ca 100644 --- a/.github/workflows/compose-smoke.yml +++ b/.github/workflows/compose-smoke.yml @@ -32,7 +32,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: persist-credentials: false fetch-depth: 0 @@ -69,7 +69,7 @@ jobs: ':(exclude)packages/modal-infra' ':(exclude)packages/daytona-infra' ':(exclude)packages/e2b-infra' - ':(exclude)packages/opencomputer-infra' + ':(exclude)packages/boat-infra' ':(exclude)packages/sandbox-runtime' ':(exclude)terraform' ':(exclude,glob)packages/control-plane/src/**/*.test.ts' @@ -110,18 +110,18 @@ jobs: - name: Setup Node.js if: steps.changes.outputs.run == 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies if: steps.changes.outputs.run == 'true' run: npm ci - - name: Test smoke change detection + - name: Test smoke tooling if: steps.changes.outputs.run == 'true' - run: node --test scripts/compose-smoke-paths.test.mjs + run: node --test scripts/compose-smoke-paths.test.mjs scripts/compose-smoke.test.mjs - name: Build shared package if: steps.changes.outputs.run == 'true' @@ -133,7 +133,7 @@ jobs: - name: Upload container logs if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: compose-smoke-logs path: smoke-logs/ diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000000..0b88affe35 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,348 @@ +# Keep the workflow unfiltered so the aggregate Coverage check can be required +# for merge without leaving unrelated pull requests pending. Each suite runs +# only when its inputs change; skipped suites still report success. +name: Coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: Detect coverage inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + control-plane: ${{ steps.changes.outputs.control-plane }} + web: ${{ steps.changes.outputs.web }} + other-ts: ${{ steps.changes.outputs.other-ts }} + python: ${{ steps.changes.outputs.python }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + - name: Check for changes affecting each coverage suite + id: changes + env: + # PRs compare from the merge base; pushes compare the complete push. + DIFF_RANGE: ${{ github.event_name == 'pull_request' && format('{0}...{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('{0}..{1}', github.event.before, github.sha) }} + run: | + # The coverage gate itself applies to every suite. + gate=( + .github/workflows/coverage.yml + .nvmrc + scripts/check-coverage.mjs + scripts/coverage-baseline.json + scripts/coverage-policy.ts + ) + # Every TypeScript suite installs the workspace and imports the built shared package. + typescript=( + package.json + package-lock.json + packages/shared + ) + # Inputs outside a suite's own packages are files its tests read or import. + # Keep these lists in sync with those reads. Package READMEs are not inputs. + detect() { + local suite=$1 + shift + # Unknown paths and unavailable comparisons run the suite. No rename + # detection: moving an input out of a watched directory must run it. + if git diff --quiet --no-renames "$DIFF_RANGE" -- "$@" ':(exclude,glob)packages/**/*.md'; then + echo "$suite=false" >> "$GITHUB_OUTPUT" + echo "No $suite coverage inputs changed: skipping." >> "$GITHUB_STEP_SUMMARY" + else + echo "$suite=true" >> "$GITHUB_OUTPUT" + fi + } + detect control-plane "${gate[@]}" "${typescript[@]}" \ + packages/control-plane \ + packages/sandbox-images \ + packages/vercel-infra \ + packages/sandbox-runtime/src/sandbox_runtime/runtime_manifest.json \ + terraform/d1/migrations \ + terraform/environments/production/workers-control-plane.tf \ + .env.example \ + docker-compose.yml \ + docs/CONTROL_PLANE_CONTAINER.md + detect web "${gate[@]}" "${typescript[@]}" \ + packages/web \ + packages/control-plane/src/session/batch-archive.ts \ + packages/control-plane/src/session/contracts.ts \ + packages/control-plane/src/session/runtime-client.ts + detect other-ts "${gate[@]}" "${typescript[@]}" \ + packages/github-bot \ + packages/linear-bot \ + packages/slack-bot \ + scripts/check-coverage.test.mjs \ + docs/AVAILABLE_MODELS.md \ + packages/docs/content/docs/models/choosing-a-model.mdx + detect python "${gate[@]}" \ + packages/modal-infra \ + packages/sandbox-runtime \ + packages/sandbox-images \ + packages/control-plane/src/image-builds/timeouts.ts \ + packages/shared/src/types/integrations.ts \ + terraform/modules/modal-app/scripts/deploy.sh + + # Control-plane coverage is CPU-bound on per-file Workers pool startup, so it is split across + # runners. Shards skip the full-suite floor; the merge job below enforces it on the merged report. + control-plane-coverage-shard: + name: Coverage (control-plane ${{ matrix.shard }}) + needs: changes + if: needs.changes.outputs.control-plane == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: ["1/8", "2/8", "3/8", "4/8", "5/8", "6/8", "7/8", "8/8"] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - name: Install and build shared + run: | + npm ci + npm run build -w @open-inspect/shared + - name: Run control-plane coverage shard + env: + COVERAGE_SHARD: "true" + SHARD: ${{ matrix.shard }} + run: >- + npm run test:coverage -w @open-inspect/control-plane -- + --shard="$SHARD" + --reporter=default --reporter=github-actions --reporter=blob + --coverage.reporter=json-summary + - name: Upload shard report + if: always() + uses: actions/upload-artifact@v7 + with: + name: control-plane-coverage-shard-${{ strategy.job-index }} + path: packages/control-plane/.vitest-reports/ + include-hidden-files: true + if-no-files-found: error + + control-plane-coverage: + name: Coverage (control-plane) + needs: control-plane-coverage-shard + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - name: Install dependencies + run: npm ci + - uses: actions/download-artifact@v8 + with: + pattern: control-plane-coverage-shard-* + path: packages/control-plane/.vitest-reports/ + merge-multiple: true + - name: Merge and enforce control-plane coverage + working-directory: packages/control-plane + run: | + npx vitest run --merge-reports=.vitest-reports --config vitest.coverage.config.ts --coverage + node ../../scripts/check-coverage.mjs control-plane coverage/coverage-summary.json + - name: Upload control-plane coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-data-control-plane + path: packages/control-plane/coverage/ + if-no-files-found: error + + web-coverage: + name: Coverage (web) + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - name: Install and build shared + run: | + npm ci + npm run build -w @open-inspect/shared + - name: Enforce web coverage + run: | + npm run test:coverage -w @open-inspect/web + node scripts/check-coverage.mjs web packages/web/coverage/coverage-summary.json + - name: Upload web coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-data-web + path: packages/web/coverage/ + if-no-files-found: error + + other-ts-coverage: + name: Coverage (shared and bots) + needs: changes + if: needs.changes.outputs.other-ts == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - name: Install and build shared + run: | + npm ci + npm run build -w @open-inspect/shared + - name: Test the coverage gate + run: node --test scripts/check-coverage.test.mjs + - name: Enforce TypeScript coverage + run: | + for package in shared slack-bot linear-bot github-bot; do + npm run test:coverage -w "@open-inspect/$package" + node scripts/check-coverage.mjs "$package" "packages/$package/coverage/coverage-summary.json" + done + - name: Upload other TypeScript coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-data-ts + path: | + packages/shared/coverage/ + packages/slack-bot/coverage/ + packages/linear-bot/coverage/ + packages/github-bot/coverage/ + if-no-files-found: error + + python-coverage: + name: Coverage (Python) + needs: changes + if: needs.changes.outputs.python == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v7 + with: + version: "0.9.7" + - name: Enforce Python statement and branch coverage + run: | + for package in modal-infra sandbox-runtime; do + uv run --frozen --project "packages/$package" --extra dev pytest "packages/$package/tests" --cov="packages/$package/src" --cov-branch --cov-report="json:packages/$package/coverage/coverage.json" + node scripts/check-coverage.mjs "$package" "packages/$package/coverage/coverage.json" + done + - name: Upload Python coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-data-python + path: | + packages/modal-infra/coverage/ + packages/sandbox-runtime/coverage/ + if-no-files-found: error + + coverage: + name: Coverage + if: ${{ always() }} + needs: + [ + changes, + control-plane-coverage-shard, + control-plane-coverage, + web-coverage, + other-ts-coverage, + python-coverage, + ] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Require successful coverage jobs + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CP_CHANGED: ${{ needs.changes.outputs.control-plane }} + WEB_CHANGED: ${{ needs.changes.outputs.web }} + TS_CHANGED: ${{ needs.changes.outputs.other-ts }} + PYTHON_CHANGED: ${{ needs.changes.outputs.python }} + CP_SHARD_RESULT: ${{ needs.control-plane-coverage-shard.result }} + CP_RESULT: ${{ needs.control-plane-coverage.result }} + WEB_RESULT: ${{ needs.web-coverage.result }} + TS_RESULT: ${{ needs.other-ts-coverage.result }} + PYTHON_RESULT: ${{ needs.python-coverage.result }} + run: | + status=0 + # A job passes when it succeeded, or was skipped because none of its inputs changed. + require() { + local job=$1 result=$2 changed=$3 + if [ "$result" = success ] || { [ "$result" = skipped ] && [ "$changed" = false ]; }; then + return + fi + echo "::error::$job coverage job result: $result" + status=1 + } + require changes "$CHANGES_RESULT" true + require control-plane-shards "$CP_SHARD_RESULT" "$CP_CHANGED" + require control-plane "$CP_RESULT" "$CP_CHANGED" + require web "$WEB_RESULT" "$WEB_CHANGED" + require other-ts "$TS_RESULT" "$TS_CHANGED" + require python "$PYTHON_RESULT" "$PYTHON_CHANGED" + exit "$status" + - if: needs.changes.outputs.control-plane == 'true' + uses: actions/download-artifact@v8 + with: + name: coverage-data-control-plane + path: packages/control-plane/coverage/ + - if: needs.changes.outputs.web == 'true' + uses: actions/download-artifact@v8 + with: + name: coverage-data-web + path: packages/web/coverage/ + - if: needs.changes.outputs.other-ts == 'true' + uses: actions/download-artifact@v8 + with: + name: coverage-data-ts + path: packages/ + - if: needs.changes.outputs.python == 'true' + uses: actions/download-artifact@v8 + with: + name: coverage-data-python + path: packages/ + - name: Upload coverage reports + if: always() && contains(needs.changes.outputs.*, 'true') + uses: actions/upload-artifact@v7 + with: + name: production-coverage + path: packages/*/coverage/ + if-no-files-found: error diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml new file mode 100644 index 0000000000..3847ed29fd --- /dev/null +++ b/.github/workflows/deploy-aws.yml @@ -0,0 +1,214 @@ +# Deploys the control-plane image to an AWS instance, and puts the previous one +# back if the new one does not come up. +# +# The Cloudflare deploy is `terraform apply` from terraform.yml. This is the +# equivalent for the AWS environments, and deliberately not the same shape: no +# Terraform runs here. The image is built and pushed, the deployed-version SSM +# parameter is moved, and the instance activates it over SSM Session Manager's +# command channel. Infrastructure changes stay with `terraform apply`. +# +# Authentication is OIDC. The token's `sub` claim names the repository *and the +# GitHub environment this job requested*, and the AWS role's trust policy pins +# both -- so an environment's rules gate AWS access, not just the UI. There is +# no AWS key in this repository. +# +# The subject names no branch, and GitHub creates a referenced environment with +# no protection rules if it does not already exist. So the environment has to +# carry a deployment-branch rule for that pin to mean anything; setting one is a +# step in docs/AWS_BRING_UP.md, not an optional hardening. +# +# Configuration lives on the GitHub environment, all of it from +# `terraform output` (see docs/AWS_BRING_UP.md): +# +# AWS_DEPLOY_ROLE_ARN github_deploy_role_arn +# AWS_REGION region +# AWS_ECR_REPOSITORY ecr_repository_url +# AWS_INSTANCE_ID instance_id +# AWS_DEPLOYED_IMAGE_PARAMETER deployed_image_parameter +# AWS_PUBLIC_URL https:// +name: Deploy (AWS) + +on: + workflow_dispatch: + inputs: + environment: + description: "GitHub environment to deploy" + type: choice + options: + - aws-staging + - aws-production + default: aws-staging + push: + branches: [main] + paths: + # What the control-plane image is built from. + - "packages/control-plane/**" + - "packages/shared/**" + - "packages/sandbox-runtime/src/sandbox_runtime/*.json" + - "terraform/d1/migrations/**" + - "package.json" + - "package-lock.json" + - "scripts/deploy-aws.sh" + - ".github/workflows/deploy-aws.yml" + +# Granted per job below. Only the deploy job mints an OIDC token, and picking an +# environment name needs nothing at all. +permissions: {} + +concurrency: + # One deploy per environment at a time, and never cancelled: a cancelled + # deploy is one that has moved the parameter and not verified the result. + group: deploy-aws-${{ inputs.environment || vars.AWS_DEPLOY_ON_PUSH_ENVIRONMENT }} + cancel-in-progress: false + +jobs: + # Which environment. Separate from the deploy job so the name is checked + # before it is used: `deploy` puts it in `environment:`, which is evaluated as + # the job starts and has no step to run first. + target: + name: Pick the target + # An installation that has configured no AWS environment skips the whole + # workflow rather than spending a runner to discover it has nothing to do. + # `vars` is readable here, which is the only reason this can be decided + # before a job starts -- `on:` has no access to it, so the alternative is a + # job that runs on every push to say it has nothing to deploy. + if: inputs.environment != '' || vars.AWS_DEPLOY_ON_PUSH_ENVIRONMENT != '' + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 2 + outputs: + environment: ${{ steps.pick.outputs.environment }} + steps: + - name: Pick the environment + id: pick + env: + DISPATCHED: ${{ inputs.environment }} + ON_PUSH: ${{ vars.AWS_DEPLOY_ON_PUSH_ENVIRONMENT }} + run: | + if [ -n "$DISPATCHED" ]; then + environment="$DISPATCHED" + else + environment="$ON_PUSH" + fi + + # A repository variable is free text somebody typed, and it goes on to + # name a job, a concurrency group and an OIDC subject. Anything that is + # not a plain environment name is a mistake, and worth failing on here + # rather than passing along. + case "$environment" in + *[!A-Za-z0-9._-]*) + echo "::error::AWS_DEPLOY_ON_PUSH_ENVIRONMENT is not a plain environment name." + exit 1 + ;; + esac + + printf 'environment=%s\n' "$environment" >>"$GITHUB_OUTPUT" + echo "deploying to $environment" + + deploy: + name: Deploy + needs: [target] + if: needs.target.outputs.environment != '' + # Native arm64: the instance is Graviton and the image has no amd64 build, + # so an x86 runner would mean QEMU and a build several times longer. + runs-on: ubuntu-24.04-arm + permissions: + contents: read + id-token: write # the OIDC token this exchanges for the deploy role + environment: + name: ${{ needs.target.outputs.environment }} + url: ${{ vars.AWS_PUBLIC_URL }} + # Wide enough for the worst case scripts/deploy-aws.sh allows -- a deploy + # that times out remotely and then a rollback that does too, about 34 + # minutes -- plus the build and push in front of it. A job cancelled part + # way through is one that has moved the parameter and not verified it. + timeout-minutes: 50 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # This job holds AWS credentials; it has no reason to also hold a git + # token. Nothing after this pushes, tags or fetches. + persist-credentials: false + + - name: Check the environment's deploy configuration + env: + ENVIRONMENT: ${{ needs.target.outputs.environment }} + AWS_DEPLOY_ROLE_ARN: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ECR_REPOSITORY: ${{ vars.AWS_ECR_REPOSITORY }} + AWS_INSTANCE_ID: ${{ vars.AWS_INSTANCE_ID }} + AWS_DEPLOYED_IMAGE_PARAMETER: ${{ vars.AWS_DEPLOYED_IMAGE_PARAMETER }} + AWS_PUBLIC_URL: ${{ vars.AWS_PUBLIC_URL }} + run: | + missing="" + for name in AWS_DEPLOY_ROLE_ARN AWS_REGION AWS_ECR_REPOSITORY \ + AWS_INSTANCE_ID AWS_DEPLOYED_IMAGE_PARAMETER AWS_PUBLIC_URL; do + [ -n "${!name}" ] || missing="$missing $name" + done + + if [ -n "$missing" ]; then + echo "::error::Environment $ENVIRONMENT is missing:$missing. Each is a \`terraform output\`; see docs/AWS_BRING_UP.md." + exit 1 + fi + + - name: Assume the deploy role + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Log in to ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Pin the current image before overwriting tags + env: + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ECR_REPOSITORY: ${{ vars.AWS_ECR_REPOSITORY }} + DEPLOYED_IMAGE_PARAMETER: ${{ vars.AWS_DEPLOYED_IMAGE_PARAMETER }} + run: scripts/deploy-aws.sh --pin-current-image + + - name: Build and push the image + id: image + env: + REPOSITORY: ${{ vars.AWS_ECR_REPOSITORY }} + REGION: ${{ vars.AWS_REGION }} + run: | + set -o pipefail + image="$REPOSITORY:$GITHUB_SHA" + + docker build -f packages/control-plane/Dockerfile -t "$image" . + docker push "$image" + + # `latest` is what a freshly applied module points a first boot at, + # before any deploy has ever moved the parameter. The previous step + # pins that bootstrap image before either tag can be overwritten. + docker tag "$image" "$REPOSITORY:latest" + docker push "$REPOSITORY:latest" + + # What gets deployed is the digest. The repository is MUTABLE, so both + # tags above move: re-running this workflow on a commit that is already + # deployed would otherwise repoint `:` at the new build, and the + # rollback -- which restores whatever string the parameter held -- would + # put back the very image it is rolling back from. + digest="$(aws ecr describe-images \ + --repository-name "${REPOSITORY#*/}" \ + --image-ids "imageTag=$GITHUB_SHA" \ + --region "$REGION" \ + --query 'imageDetails[0].imageDigest' --output text)" + + case "$digest" in + sha256:*) ;; + *) echo "::error::could not resolve $image to a digest (got '$digest')"; exit 1 ;; + esac + + echo "ref=$REPOSITORY@$digest" >>"$GITHUB_OUTPUT" + + - name: Deploy, verify, and roll back if unhealthy + env: + AWS_REGION: ${{ vars.AWS_REGION }} + INSTANCE_ID: ${{ vars.AWS_INSTANCE_ID }} + DEPLOYED_IMAGE_PARAMETER: ${{ vars.AWS_DEPLOYED_IMAGE_PARAMETER }} + HEALTHCHECK_URL: ${{ vars.AWS_PUBLIC_URL }}/healthz + IMAGE_REF: ${{ steps.image.outputs.ref }} + run: scripts/deploy-aws.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000000..0f4ad73b9c --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,71 @@ +name: Deploy Docs + +# Docs publication is manual: a maintainer runs this workflow from the Actions +# tab after CI is green on main. It only runs for the main branch and deploys +# the commit at the tip of main when it starts. +on: + workflow_dispatch: + +permissions: + contents: read + +# Queue a second request behind an active deployment instead of cancelling it. +concurrency: + group: deploy-docs-production + cancel-in-progress: false + +env: + VERCEL_ORG_ID: ${{ vars.VERCEL_TEAM_ID || secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOCS_PROJECT_ID || secrets.VERCEL_DOCS_PROJECT_ID }} + +jobs: + deploy: + name: Deploy docs to Vercel + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # A maintainer asked for a deployment, so a run that cannot publish has to + # fail. Skipping the remaining steps would report success for a deployment + # that never happened. + - name: Require a publishable ref and Vercel credentials + env: + VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }} + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::Deploy Docs publishes main only; this run targets $GITHUB_REF." + exit 1 + fi + missing="" + [ -z "$VERCEL_API_TOKEN" ] && missing="$missing VERCEL_API_TOKEN" + [ -z "$VERCEL_ORG_ID" ] && missing="$missing VERCEL_TEAM_ID" + [ -z "$VERCEL_PROJECT_ID" ] && missing="$missing VERCEL_DOCS_PROJECT_ID" + if [ -n "$missing" ]; then + echo "::error::Missing Vercel configuration (repository variable or secret):$missing" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Vercel CLI 58.4.0 + run: npm install -g vercel@58.4.0 + + - name: Pull Vercel environment + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_API_TOKEN }} + + - name: Build docs + run: vercel build --prod --token=${{ secrets.VERCEL_API_TOKEN }} + + - name: Deploy docs + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_API_TOKEN }} diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index d5c6b69d63..7fbac921d3 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -7,6 +7,7 @@ on: - "packages/web/**" - "packages/shared/**" - ".github/workflows/deploy-web.yml" + - ".nvmrc" workflow_dispatch: # Allow manual trigger permissions: @@ -41,13 +42,13 @@ jobs: - name: Checkout if: steps.check-secrets.outputs.configured == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js if: steps.check-secrets.outputs.configured == 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -58,6 +59,11 @@ jobs: if: steps.check-secrets.outputs.configured == 'true' run: npm run build -w @open-inspect/shared + - name: Build isolated diagram renderer + if: steps.check-secrets.outputs.configured == 'true' + # Generate even when the remote project still has the old `next build` command. + run: npm run build:mermaid -w @open-inspect/web + - name: Install Vercel CLI 58.4.0 if: steps.check-secrets.outputs.configured == 'true' run: npm install -g vercel@58.4.0 diff --git a/.github/workflows/sandbox-images.yml b/.github/workflows/sandbox-images.yml index 2e6b13f621..9e0f70ba77 100644 --- a/.github/workflows/sandbox-images.yml +++ b/.github/workflows/sandbox-images.yml @@ -9,6 +9,7 @@ on: - "terraform/modules/*/scripts/**" - "package-lock.json" - ".github/workflows/sandbox-images.yml" + - ".nvmrc" workflow_dispatch: permissions: @@ -26,14 +27,16 @@ jobs: base: python:3.12-slim-bookworm - provider: vercel base: amazonlinux:2023 + - provider: boat + base: ubuntu:24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: - node-version: "22" - - uses: astral-sh/setup-uv@v6 + node-version-file: .nvmrc + - uses: astral-sh/setup-uv@v7 with: version: "0.9.7" - name: Check frozen image inputs and planner regressions diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 55b0dc9673..58e7196e13 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -12,18 +12,15 @@ on: - "packages/linear-bot/**" - "packages/modal-infra/**" - "packages/daytona-infra/**" - - "packages/opencomputer-infra/**" - "packages/e2b-infra/**" + - "packages/boat-infra/**" - "packages/sandbox-runtime/**" - "packages/sandbox-images/**" - "packages/vercel-infra/**" - "packages/shared/**" - "packages/web/**" # Web app deployed via Terraform when web_platform = "cloudflare" - # Uploaded verbatim by modules/aws-control-plane, so a change to either - # changes the stack the AWS instance runs. - - "docker-compose.yml" - - "docker-compose.aws.yml" - ".github/workflows/terraform.yml" + - ".nvmrc" pull_request: branches: [main] paths: @@ -35,24 +32,19 @@ on: - "packages/linear-bot/**" - "packages/modal-infra/**" - "packages/daytona-infra/**" - - "packages/opencomputer-infra/**" - "packages/e2b-infra/**" + - "packages/boat-infra/**" - "packages/sandbox-runtime/**" - "packages/sandbox-images/**" - "packages/vercel-infra/**" - "packages/shared/**" - "packages/web/**" - # Uploaded verbatim by modules/aws-control-plane, so a change to either - # changes the stack the AWS instance runs. - - "docker-compose.yml" - - "docker-compose.aws.yml" - ".github/workflows/terraform.yml" + - ".nvmrc" workflow_dispatch: # Allow manual trigger permissions: contents: read - pull-requests: write - issues: write concurrency: group: terraform-${{ github.ref }} @@ -73,7 +65,18 @@ jobs: - name: Check if secrets are configured id: check run: | - if [ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" ] && [ -n "${{ secrets.R2_ACCESS_KEY_ID }}" ]; then + # Gate on every credential the plan and apply jobs consume to reach a + # backend, not just two of them. A partial configuration used to report + # has-secrets=true and then fail inside `terraform init` with an empty + # access/secret key or an endpoint URL of "https://.r2.cloudflarestorage.com", + # instead of skipping with the notice below. CLOUDFLARE_ACCOUNT_ID is read + # with the same `vars || secrets` precedence the init steps use, so a + # variable-configured deployment is not mistaken for an unconfigured one. + account_id="${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}" + if [ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" ] && + [ -n "${{ secrets.R2_ACCESS_KEY_ID }}" ] && + [ -n "${{ secrets.R2_SECRET_ACCESS_KEY }}" ] && + [ -n "$account_id" ]; then echo "has-secrets=true" >> $GITHUB_OUTPUT else echo "has-secrets=false" >> $GITHUB_OUTPUT @@ -85,9 +88,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 needs: [check-secrets] + outputs: + fmt: ${{ steps.fmt.outcome }} + init: ${{ steps.init.outcome }} + validate: ${{ steps.validate.outcome }} + test: ${{ steps.test.outcome }} + modal-test: ${{ steps.modal_test.outcome }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Terraform uses: hashicorp/setup-terraform@v4 @@ -110,25 +119,6 @@ jobs: run: terraform validate -no-color working-directory: ${{ env.TF_WORKING_DIR }} - # `validate` reads HCL only: it never renders the instance bootstrap, and - # the compose smoke boots a different overlay. This is the only thing that - # parses either. - - name: AWS stack static checks - id: aws_stack - run: scripts/check-aws-stack.sh - - # Not applied from CI yet -- that is I-3 -- so this is the only thing that - # parses them. The module is shared, so a break here is a break there. - - name: Terraform Init and Validate (AWS environments) - id: validate_aws - run: | - for environment in aws-staging aws-production; do - echo "::group::$environment" - terraform -chdir="terraform/environments/$environment" init -backend=false -no-color - terraform -chdir="terraform/environments/$environment" validate -no-color - echo "::endgroup::" - done - # Unfiltered so a new tests/*.tftest.hcl file is gated the moment it lands; # naming each file here silently left new ones unrun. - name: Terraform Tests @@ -136,42 +126,12 @@ jobs: run: terraform test working-directory: ${{ env.TF_WORKING_DIR }} - - name: Post Validation Results - if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v8 - with: - script: | - const hasSecrets = '${{ needs.check-secrets.outputs.has-secrets }}' === 'true'; - const planNote = hasSecrets - ? '' - : '\n\n> **Note:** Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) for setup instructions.'; - - const output = `### Terraform Validation Results - - | Step | Status | - |------|--------| - | Format | ${{ steps.fmt.outcome == 'success' && '✅' || '⚠️' }} | - | Init | ${{ steps.init.outcome == 'success' && '✅' || '❌' }} | - | Validate | ${{ steps.validate.outcome == 'success' && '✅' || '❌' }} | - | Tests | ${{ steps.test.outcome == 'success' && '✅' || '❌' }} | - ${planNote} - - *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`; - - try { - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: output - }); - } catch (error) { - if (error.status === 403) { - core.notice('Could not post PR comment — GITHUB_TOKEN has read-only access (expected for fork PRs).'); - } else { - throw error; - } - } + - name: Modal module contract tests + id: modal_test + run: | + terraform init -backend=false + terraform test + working-directory: terraform/modules/modal-app plan: name: Plan @@ -179,14 +139,16 @@ jobs: timeout-minutes: 15 needs: [validate, check-secrets] if: github.event_name == 'pull_request' && needs.check-secrets.outputs.has-secrets == 'true' + outputs: + outcome: ${{ steps.plan.outcome }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -204,6 +166,8 @@ jobs: uses: hashicorp/setup-terraform@v4 with: terraform_version: ${{ env.TF_VERSION }} + # Capture output as a file, not the wrapper's multiline step outputs. + terraform_wrapper: false - name: Terraform Init env: @@ -231,17 +195,24 @@ jobs: - name: Terraform Plan id: plan + shell: bash run: | + set -euo pipefail + # Plan text must not be interpreted as runner workflow commands either. + command_token="$(openssl rand -hex 32)" + echo "::stop-commands::$command_token" + trap 'printf "\n::%s::\n" "$command_token"' EXIT terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt - echo "plan<> $GITHUB_OUTPUT - cat plan_output.txt | head -c 60000 >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT working-directory: ${{ env.TF_WORKING_DIR }} continue-on-error: true env: TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} TF_VAR_cloudflare_account_id: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} TF_VAR_cloudflare_worker_subdomain: ${{ vars.CLOUDFLARE_WORKER_SUBDOMAIN || secrets.CLOUDFLARE_WORKER_SUBDOMAIN }} + TF_VAR_cloudflare_zone_id: ${{ vars.CLOUDFLARE_ZONE_ID || secrets.CLOUDFLARE_ZONE_ID }} + TF_VAR_cloudflare_custom_domain: ${{ vars.CLOUDFLARE_CUSTOM_DOMAIN || secrets.CLOUDFLARE_CUSTOM_DOMAIN }} + TF_VAR_r2_media_location: "${{ vars.R2_MEDIA_LOCATION || secrets.R2_MEDIA_LOCATION || 'ENAM' }}" + TF_VAR_r2_media_bucket_name: ${{ vars.R2_MEDIA_BUCKET_NAME || secrets.R2_MEDIA_BUCKET_NAME }} TF_VAR_vercel_api_token: "${{ secrets.VERCEL_API_TOKEN || '000000000000000000000000' }}" TF_VAR_vercel_team_id: "${{ vars.VERCEL_TEAM_ID || secrets.VERCEL_TEAM_ID || 'unused' }}" TF_VAR_modal_token_id: ${{ secrets.MODAL_TOKEN_ID }} @@ -261,8 +232,11 @@ jobs: TF_VAR_slack_signing_secret: ${{ secrets.SLACK_SIGNING_SECRET }} TF_VAR_slack_bot_default_model: "${{ vars.SLACK_BOT_DEFAULT_MODEL || secrets.SLACK_BOT_DEFAULT_MODEL || 'claude-haiku-4-5' }}" TF_VAR_anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + TF_VAR_classification_anthropic_api_key: ${{ secrets.CLASSIFICATION_ANTHROPIC_API_KEY }} TF_VAR_classification_openai_api_key: ${{ secrets.CLASSIFICATION_OPENAI_API_KEY }} + TF_VAR_classification_bedrock_api_key: ${{ secrets.CLASSIFICATION_BEDROCK_API_KEY }} TF_VAR_classification_model: "${{ vars.CLASSIFICATION_MODEL || 'claude-haiku-4-5' }}" + TF_VAR_classification_reasoning_effort: "${{ vars.CLASSIFICATION_REASONING_EFFORT }}" TF_VAR_token_encryption_key: ${{ secrets.TOKEN_ENCRYPTION_KEY }} TF_VAR_repo_secrets_encryption_key: ${{ secrets.REPO_SECRETS_ENCRYPTION_KEY }} TF_VAR_provider_accounts_encryption_key: ${{ secrets.PROVIDER_ACCOUNTS_ENCRYPTION_KEY }} @@ -272,6 +246,7 @@ jobs: TF_VAR_allowed_email_domains: ${{ vars.ALLOWED_EMAIL_DOMAINS || secrets.ALLOWED_EMAIL_DOMAINS }} TF_VAR_allowed_emails: ${{ vars.ALLOWED_EMAILS || secrets.ALLOWED_EMAILS }} TF_VAR_allowed_github_orgs: ${{ vars.ALLOWED_GITHUB_ORGS || secrets.ALLOWED_GITHUB_ORGS }} + TF_VAR_unsafe_allow_all_users: "${{ vars.UNSAFE_ALLOW_ALL_USERS || secrets.UNSAFE_ALLOW_ALL_USERS || 'false' }}" TF_VAR_deployment_name: ${{ vars.DEPLOYMENT_NAME || secrets.DEPLOYMENT_NAME }} TF_VAR_enable_github_bot: "${{ vars.ENABLE_GITHUB_BOT || secrets.ENABLE_GITHUB_BOT || 'false' }}" TF_VAR_github_webhook_secret: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -283,14 +258,25 @@ jobs: TF_VAR_linear_client_id: ${{ vars.LINEAR_CLIENT_ID || secrets.LINEAR_CLIENT_ID }} TF_VAR_linear_client_secret: ${{ secrets.LINEAR_CLIENT_SECRET }} TF_VAR_linear_webhook_secret: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + TF_VAR_linear_api_key: ${{ secrets.LINEAR_API_KEY }} TF_VAR_linear_bot_default_model: "${{ vars.LINEAR_BOT_DEFAULT_MODEL || secrets.LINEAR_BOT_DEFAULT_MODEL || 'claude-sonnet-4-6' }}" TF_VAR_web_platform: "${{ vars.WEB_PLATFORM || secrets.WEB_PLATFORM || 'vercel' }}" + TF_VAR_web_serverless_region: "${{ vars.WEB_SERVERLESS_REGION || 'iad1' }}" TF_VAR_enable_durable_object_bindings: "${{ vars.ENABLE_DURABLE_OBJECT_BINDINGS || secrets.ENABLE_DURABLE_OBJECT_BINDINGS || 'true' }}" + TF_VAR_enable_service_bindings: "${{ vars.ENABLE_SERVICE_BINDINGS || secrets.ENABLE_SERVICE_BINDINGS || 'true' }}" TF_VAR_sandbox_provider: "${{ vars.SANDBOX_PROVIDER || secrets.SANDBOX_PROVIDER || 'modal' }}" + TF_VAR_session_do_location_hint: "${{ vars.SESSION_DO_LOCATION_HINT || '' }}" + TF_VAR_sandbox_inactivity_timeout_ms: "${{ vars.SANDBOX_INACTIVITY_TIMEOUT_MS || secrets.SANDBOX_INACTIVITY_TIMEOUT_MS || '600000' }}" + TF_VAR_sandbox_unattended_inactivity_timeout_ms: "${{ vars.SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS || secrets.SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS || '120000' }}" + TF_VAR_teams_enforcement: "${{ vars.TEAMS_ENFORCEMENT || secrets.TEAMS_ENFORCEMENT || 'shadow' }}" + TF_VAR_sandbox_boot_timeout_ms: "${{ vars.SANDBOX_BOOT_TIMEOUT_MS || secrets.SANDBOX_BOOT_TIMEOUT_MS || '1800000' }}" TF_VAR_daytona_api_url: ${{ vars.DAYTONA_API_URL || secrets.DAYTONA_API_URL }} TF_VAR_daytona_api_key: ${{ secrets.DAYTONA_API_KEY }} TF_VAR_daytona_base_snapshot: ${{ vars.DAYTONA_BASE_SNAPSHOT || secrets.DAYTONA_BASE_SNAPSHOT }} + TF_VAR_daytona_base_snapshot_memory_gib: "${{ vars.DAYTONA_BASE_SNAPSHOT_MEMORY_GIB || '2' }}" TF_VAR_daytona_target: ${{ vars.DAYTONA_TARGET || secrets.DAYTONA_TARGET }} + TF_VAR_daytona_toolbox_api_url: ${{ vars.DAYTONA_TOOLBOX_API_URL || secrets.DAYTONA_TOOLBOX_API_URL }} + TF_VAR_daytona_prebuilds_enabled: "${{ vars.DAYTONA_PREBUILDS_ENABLED || secrets.DAYTONA_PREBUILDS_ENABLED || 'false' }}" TF_VAR_vercel_sandbox_token: ${{ secrets.VERCEL_SANDBOX_TOKEN }} TF_VAR_vercel_sandbox_project_id: ${{ vars.VERCEL_SANDBOX_PROJECT_ID || secrets.VERCEL_SANDBOX_PROJECT_ID }} TF_VAR_vercel_sandbox_team_id: ${{ vars.VERCEL_SANDBOX_TEAM_ID || secrets.VERCEL_SANDBOX_TEAM_ID }} @@ -298,13 +284,6 @@ jobs: TF_VAR_vercel_base_snapshot_id: ${{ vars.VERCEL_BASE_SNAPSHOT_ID || secrets.VERCEL_BASE_SNAPSHOT_ID }} TF_VAR_vercel_sandbox_runtime: "${{ vars.VERCEL_SANDBOX_RUNTIME || secrets.VERCEL_SANDBOX_RUNTIME || 'node24' }}" TF_VAR_vercel_snapshot_expiration_ms: "${{ vars.VERCEL_SNAPSHOT_EXPIRATION_MS || secrets.VERCEL_SNAPSHOT_EXPIRATION_MS || '0' }}" - # Sandbox provider: OpenComputer. Inert while sandbox_provider defaults to "modal"; - # when set to "opencomputer", Terraform builds the managed base snapshot during apply. - TF_VAR_opencomputer_api_url: ${{ vars.OPENCOMPUTER_API_URL || secrets.OPENCOMPUTER_API_URL }} - TF_VAR_opencomputer_api_key: ${{ secrets.OPENCOMPUTER_API_KEY }} - TF_VAR_opencomputer_template: ${{ vars.OPENCOMPUTER_TEMPLATE || secrets.OPENCOMPUTER_TEMPLATE }} - TF_VAR_opencomputer_project_id: ${{ vars.OPENCOMPUTER_PROJECT_ID || secrets.OPENCOMPUTER_PROJECT_ID }} - TF_VAR_opencomputer_target: ${{ vars.OPENCOMPUTER_TARGET || secrets.OPENCOMPUTER_TARGET }} TF_VAR_e2b_api_key: ${{ secrets.E2B_API_KEY }} TF_VAR_e2b_template_id: ${{ vars.E2B_TEMPLATE_ID || secrets.E2B_TEMPLATE_ID }} TF_VAR_e2b_api_url: "${{ vars.E2B_API_URL || secrets.E2B_API_URL || 'https://api.e2b.app' }}" @@ -312,42 +291,137 @@ jobs: TF_VAR_e2b_auto_pause: "${{ vars.E2B_AUTO_PAUSE || secrets.E2B_AUTO_PAUSE || 'true' }}" TF_VAR_e2b_template_cpu: "${{ vars.E2B_TEMPLATE_CPU || secrets.E2B_TEMPLATE_CPU || '2' }}" TF_VAR_e2b_template_memory_mb: "${{ vars.E2B_TEMPLATE_MEMORY_MB || secrets.E2B_TEMPLATE_MEMORY_MB || '4096' }}" + TF_VAR_boat_api_key: ${{ secrets.BOAT_API_KEY }} + TF_VAR_boat_api_url: "${{ vars.BOAT_API_URL || secrets.BOAT_API_URL || 'https://boat.dev/api/v1' }}" + TF_VAR_boat_base_snapshot: ${{ vars.BOAT_BASE_SNAPSHOT || secrets.BOAT_BASE_SNAPSHOT }} + TF_VAR_boat_org: ${{ vars.BOAT_ORG || secrets.BOAT_ORG }} + TF_VAR_boat_sandbox_type: "${{ vars.BOAT_SANDBOX_TYPE || secrets.BOAT_SANDBOX_TYPE || 'default' }}" + # Documentation site (packages/docs). Leaving DOCS_SITE_ENABLED unset + # after provisioning the site destroys its Vercel project on apply. + TF_VAR_docs_site_enabled: "${{ vars.DOCS_SITE_ENABLED || secrets.DOCS_SITE_ENABLED || 'false' }}" + TF_VAR_docs_custom_domain: ${{ vars.DOCS_CUSTOM_DOMAIN || secrets.DOCS_CUSTOM_DOMAIN }} + + - name: Prepare Plan Comment + id: prepare_comment + if: always() && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') + env: + PLAN_OUTCOME: ${{ steps.plan.outcome }} + run: node "$GITHUB_WORKSPACE/scripts/terraform-plan-comment.mjs" plan_output.txt plan_comment.txt "$PLAN_OUTCOME" + working-directory: ${{ env.TF_WORKING_DIR }} - - name: Post Plan Results - uses: actions/github-script@v8 + - name: Upload Plan Comment + if: steps.prepare_comment.conclusion == 'success' + uses: actions/upload-artifact@v7 with: - script: | - const planOutcome = '${{ steps.plan.outcome }}'; - const plan = `${{ steps.plan.outputs.plan }}`; - - const truncatedPlan = plan.length > 60000 - ? plan.substring(0, 60000) + '\n... (truncated)' - : plan; + name: terraform-plan-comment + path: ${{ env.TF_WORKING_DIR }}/plan_comment.txt + if-no-files-found: error + retention-days: 1 - const output = `### Terraform Plan Results + - name: Plan Status + if: always() && steps.plan.outcome == 'failure' + run: exit 1 - **Status:** ${planOutcome === 'success' ? '✅ Success' : '❌ Failed'} + comment: + name: Comment + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [validate, check-secrets, plan] + if: always() && !cancelled() && github.event_name == 'pull_request' + permissions: + pull-requests: write + # No checkout or execution of PR code. Artifacts are read only as comment data. + steps: + - name: Post Validation Results + uses: actions/github-script@v9 + env: + HAS_SECRETS: ${{ needs.check-secrets.outputs.has-secrets }} + CHECK_SECRETS_RESULT: ${{ needs.check-secrets.result }} + VALIDATION_RESULT: ${{ needs.validate.result }} + FORMAT_OUTCOME: ${{ needs.validate.outputs.fmt }} + INIT_OUTCOME: ${{ needs.validate.outputs.init }} + VALIDATE_OUTCOME: ${{ needs.validate.outputs.validate }} + TEST_OUTCOME: ${{ needs.validate.outputs.test }} + MODAL_TEST_OUTCOME: ${{ needs.validate.outputs.modal-test }} + with: + script: | + const validationResult = process.env.VALIDATION_RESULT; + const checkSecretsResult = process.env.CHECK_SECRETS_RESULT; + const statuses = { + Format: process.env.FORMAT_OUTCOME, + Init: process.env.INIT_OUTCOME, + Validate: process.env.VALIDATE_OUTCOME, + Tests: process.env.TEST_OUTCOME, + 'Modal module tests': process.env.MODAL_TEST_OUTCOME + }; + const rows = Object.entries(statuses).map(([step, outcome]) => + `| ${step} | ${outcome === 'success' ? 'Success' : outcome || (validationResult === 'skipped' ? 'skipped' : 'Not reported')} |` + ).join('\n'); + let planNote = ''; + if (checkSecretsResult !== 'success') { + planNote = '\n\n> **Warning:** Secret availability is unknown because the Check Secrets job did not succeed.'; + } else if (process.env.HAS_SECRETS === 'false') { + planNote = '\n\n> **Note:** Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) for setup instructions.'; + } else if (process.env.HAS_SECRETS !== 'true') { + planNote = '\n\n> **Warning:** Check Secrets completed without a valid secret-availability result.'; + } + const output = `### Terraform Validation Results -
Show Plan + **Validation job:** ${validationResult || 'unknown'} + **Check Secrets job:** ${checkSecretsResult || 'unknown'} - \`\`\`terraform - ${truncatedPlan} - \`\`\` + | Step | Status | + |------|--------| + ${rows} + ${planNote} -
+ *Pushed by: @${context.actor}, Action: \`${context.eventName}\`*`; - *Pushed by: @${{ github.actor }}*`; + try { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }); + } catch (error) { + if (error.status === 403) { + core.notice('Could not post PR comment: GITHUB_TOKEN has read-only access (expected for fork PRs).'); + } else { + throw error; + } + } - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: output - }); + - name: Download Plan Comment + if: needs.plan.outputs.outcome == 'success' || needs.plan.outputs.outcome == 'failure' + uses: actions/download-artifact@v8 + with: + name: terraform-plan-comment + path: ${{ runner.temp }}/terraform-plan-comment - - name: Plan Status - if: steps.plan.outcome == 'failure' - run: exit 1 + - name: Post Plan Results + if: needs.plan.outputs.outcome == 'success' || needs.plan.outputs.outcome == 'failure' + uses: actions/github-script@v9 + env: + PLAN_COMMENT_PATH: ${{ runner.temp }}/terraform-plan-comment/plan_comment.txt + with: + script: | + const fs = require('node:fs'); + const body = fs.readFileSync(process.env.PLAN_COMMENT_PATH, 'utf8'); + try { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } catch (error) { + if (error.status === 403) { + core.notice('Could not post PR comment: GITHUB_TOKEN has read-only access (expected for fork PRs).'); + } else { + throw error; + } + } apply: name: Apply @@ -358,12 +432,12 @@ jobs: environment: production steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: .nvmrc cache: "npm" - name: Install dependencies @@ -378,7 +452,7 @@ jobs: npm run build -w @open-inspect/linear-bot - name: Setup uv - uses: astral-sh/setup-uv@v8.2.0 + uses: astral-sh/setup-uv@v7 - name: Install Modal and Python dependencies run: uv sync --frozen @@ -420,6 +494,10 @@ jobs: TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} TF_VAR_cloudflare_account_id: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} TF_VAR_cloudflare_worker_subdomain: ${{ vars.CLOUDFLARE_WORKER_SUBDOMAIN || secrets.CLOUDFLARE_WORKER_SUBDOMAIN }} + TF_VAR_cloudflare_zone_id: ${{ vars.CLOUDFLARE_ZONE_ID || secrets.CLOUDFLARE_ZONE_ID }} + TF_VAR_cloudflare_custom_domain: ${{ vars.CLOUDFLARE_CUSTOM_DOMAIN || secrets.CLOUDFLARE_CUSTOM_DOMAIN }} + TF_VAR_r2_media_location: "${{ vars.R2_MEDIA_LOCATION || secrets.R2_MEDIA_LOCATION || 'ENAM' }}" + TF_VAR_r2_media_bucket_name: ${{ vars.R2_MEDIA_BUCKET_NAME || secrets.R2_MEDIA_BUCKET_NAME }} TF_VAR_vercel_api_token: "${{ secrets.VERCEL_API_TOKEN || '000000000000000000000000' }}" TF_VAR_vercel_team_id: "${{ vars.VERCEL_TEAM_ID || secrets.VERCEL_TEAM_ID || 'unused' }}" TF_VAR_modal_token_id: ${{ secrets.MODAL_TOKEN_ID }} @@ -439,8 +517,11 @@ jobs: TF_VAR_slack_signing_secret: ${{ secrets.SLACK_SIGNING_SECRET }} TF_VAR_slack_bot_default_model: "${{ vars.SLACK_BOT_DEFAULT_MODEL || secrets.SLACK_BOT_DEFAULT_MODEL || 'claude-haiku-4-5' }}" TF_VAR_anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + TF_VAR_classification_anthropic_api_key: ${{ secrets.CLASSIFICATION_ANTHROPIC_API_KEY }} TF_VAR_classification_openai_api_key: ${{ secrets.CLASSIFICATION_OPENAI_API_KEY }} + TF_VAR_classification_bedrock_api_key: ${{ secrets.CLASSIFICATION_BEDROCK_API_KEY }} TF_VAR_classification_model: "${{ vars.CLASSIFICATION_MODEL || 'claude-haiku-4-5' }}" + TF_VAR_classification_reasoning_effort: "${{ vars.CLASSIFICATION_REASONING_EFFORT }}" TF_VAR_token_encryption_key: ${{ secrets.TOKEN_ENCRYPTION_KEY }} TF_VAR_repo_secrets_encryption_key: ${{ secrets.REPO_SECRETS_ENCRYPTION_KEY }} TF_VAR_provider_accounts_encryption_key: ${{ secrets.PROVIDER_ACCOUNTS_ENCRYPTION_KEY }} @@ -450,6 +531,7 @@ jobs: TF_VAR_allowed_email_domains: ${{ vars.ALLOWED_EMAIL_DOMAINS || secrets.ALLOWED_EMAIL_DOMAINS }} TF_VAR_allowed_emails: ${{ vars.ALLOWED_EMAILS || secrets.ALLOWED_EMAILS }} TF_VAR_allowed_github_orgs: ${{ vars.ALLOWED_GITHUB_ORGS || secrets.ALLOWED_GITHUB_ORGS }} + TF_VAR_unsafe_allow_all_users: "${{ vars.UNSAFE_ALLOW_ALL_USERS || secrets.UNSAFE_ALLOW_ALL_USERS || 'false' }}" TF_VAR_deployment_name: ${{ vars.DEPLOYMENT_NAME || secrets.DEPLOYMENT_NAME }} TF_VAR_enable_github_bot: "${{ vars.ENABLE_GITHUB_BOT || secrets.ENABLE_GITHUB_BOT || 'false' }}" TF_VAR_github_webhook_secret: ${{ secrets.GH_WEBHOOK_SECRET }} @@ -461,14 +543,25 @@ jobs: TF_VAR_linear_client_id: ${{ vars.LINEAR_CLIENT_ID || secrets.LINEAR_CLIENT_ID }} TF_VAR_linear_client_secret: ${{ secrets.LINEAR_CLIENT_SECRET }} TF_VAR_linear_webhook_secret: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + TF_VAR_linear_api_key: ${{ secrets.LINEAR_API_KEY }} TF_VAR_linear_bot_default_model: "${{ vars.LINEAR_BOT_DEFAULT_MODEL || secrets.LINEAR_BOT_DEFAULT_MODEL || 'claude-sonnet-4-6' }}" TF_VAR_web_platform: "${{ vars.WEB_PLATFORM || secrets.WEB_PLATFORM || 'vercel' }}" + TF_VAR_web_serverless_region: "${{ vars.WEB_SERVERLESS_REGION || 'iad1' }}" TF_VAR_enable_durable_object_bindings: "${{ vars.ENABLE_DURABLE_OBJECT_BINDINGS || secrets.ENABLE_DURABLE_OBJECT_BINDINGS || 'true' }}" + TF_VAR_enable_service_bindings: "${{ vars.ENABLE_SERVICE_BINDINGS || secrets.ENABLE_SERVICE_BINDINGS || 'true' }}" TF_VAR_sandbox_provider: "${{ vars.SANDBOX_PROVIDER || secrets.SANDBOX_PROVIDER || 'modal' }}" + TF_VAR_session_do_location_hint: "${{ vars.SESSION_DO_LOCATION_HINT || '' }}" + TF_VAR_sandbox_inactivity_timeout_ms: "${{ vars.SANDBOX_INACTIVITY_TIMEOUT_MS || secrets.SANDBOX_INACTIVITY_TIMEOUT_MS || '600000' }}" + TF_VAR_sandbox_unattended_inactivity_timeout_ms: "${{ vars.SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS || secrets.SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS || '120000' }}" + TF_VAR_teams_enforcement: "${{ vars.TEAMS_ENFORCEMENT || secrets.TEAMS_ENFORCEMENT || 'shadow' }}" + TF_VAR_sandbox_boot_timeout_ms: "${{ vars.SANDBOX_BOOT_TIMEOUT_MS || secrets.SANDBOX_BOOT_TIMEOUT_MS || '1800000' }}" TF_VAR_daytona_api_url: ${{ vars.DAYTONA_API_URL || secrets.DAYTONA_API_URL }} TF_VAR_daytona_api_key: ${{ secrets.DAYTONA_API_KEY }} TF_VAR_daytona_base_snapshot: ${{ vars.DAYTONA_BASE_SNAPSHOT || secrets.DAYTONA_BASE_SNAPSHOT }} + TF_VAR_daytona_base_snapshot_memory_gib: "${{ vars.DAYTONA_BASE_SNAPSHOT_MEMORY_GIB || '2' }}" TF_VAR_daytona_target: ${{ vars.DAYTONA_TARGET || secrets.DAYTONA_TARGET }} + TF_VAR_daytona_toolbox_api_url: ${{ vars.DAYTONA_TOOLBOX_API_URL || secrets.DAYTONA_TOOLBOX_API_URL }} + TF_VAR_daytona_prebuilds_enabled: "${{ vars.DAYTONA_PREBUILDS_ENABLED || secrets.DAYTONA_PREBUILDS_ENABLED || 'false' }}" TF_VAR_vercel_sandbox_token: ${{ secrets.VERCEL_SANDBOX_TOKEN }} TF_VAR_vercel_sandbox_project_id: ${{ vars.VERCEL_SANDBOX_PROJECT_ID || secrets.VERCEL_SANDBOX_PROJECT_ID }} TF_VAR_vercel_sandbox_team_id: ${{ vars.VERCEL_SANDBOX_TEAM_ID || secrets.VERCEL_SANDBOX_TEAM_ID }} @@ -476,13 +569,6 @@ jobs: TF_VAR_vercel_base_snapshot_id: ${{ vars.VERCEL_BASE_SNAPSHOT_ID || secrets.VERCEL_BASE_SNAPSHOT_ID }} TF_VAR_vercel_sandbox_runtime: "${{ vars.VERCEL_SANDBOX_RUNTIME || secrets.VERCEL_SANDBOX_RUNTIME || 'node24' }}" TF_VAR_vercel_snapshot_expiration_ms: "${{ vars.VERCEL_SNAPSHOT_EXPIRATION_MS || secrets.VERCEL_SNAPSHOT_EXPIRATION_MS || '0' }}" - # Sandbox provider: OpenComputer. Inert while sandbox_provider defaults to "modal"; - # when set to "opencomputer", Terraform builds the managed base snapshot during apply. - TF_VAR_opencomputer_api_url: ${{ vars.OPENCOMPUTER_API_URL || secrets.OPENCOMPUTER_API_URL }} - TF_VAR_opencomputer_api_key: ${{ secrets.OPENCOMPUTER_API_KEY }} - TF_VAR_opencomputer_template: ${{ vars.OPENCOMPUTER_TEMPLATE || secrets.OPENCOMPUTER_TEMPLATE }} - TF_VAR_opencomputer_project_id: ${{ vars.OPENCOMPUTER_PROJECT_ID || secrets.OPENCOMPUTER_PROJECT_ID }} - TF_VAR_opencomputer_target: ${{ vars.OPENCOMPUTER_TARGET || secrets.OPENCOMPUTER_TARGET }} TF_VAR_e2b_api_key: ${{ secrets.E2B_API_KEY }} TF_VAR_e2b_template_id: ${{ vars.E2B_TEMPLATE_ID || secrets.E2B_TEMPLATE_ID }} TF_VAR_e2b_api_url: "${{ vars.E2B_API_URL || secrets.E2B_API_URL || 'https://api.e2b.app' }}" @@ -490,6 +576,15 @@ jobs: TF_VAR_e2b_auto_pause: "${{ vars.E2B_AUTO_PAUSE || secrets.E2B_AUTO_PAUSE || 'true' }}" TF_VAR_e2b_template_cpu: "${{ vars.E2B_TEMPLATE_CPU || secrets.E2B_TEMPLATE_CPU || '2' }}" TF_VAR_e2b_template_memory_mb: "${{ vars.E2B_TEMPLATE_MEMORY_MB || secrets.E2B_TEMPLATE_MEMORY_MB || '4096' }}" + TF_VAR_boat_api_key: ${{ secrets.BOAT_API_KEY }} + TF_VAR_boat_api_url: "${{ vars.BOAT_API_URL || secrets.BOAT_API_URL || 'https://boat.dev/api/v1' }}" + TF_VAR_boat_base_snapshot: ${{ vars.BOAT_BASE_SNAPSHOT || secrets.BOAT_BASE_SNAPSHOT }} + TF_VAR_boat_org: ${{ vars.BOAT_ORG || secrets.BOAT_ORG }} + TF_VAR_boat_sandbox_type: "${{ vars.BOAT_SANDBOX_TYPE || secrets.BOAT_SANDBOX_TYPE || 'default' }}" + # Documentation site (packages/docs). Leaving DOCS_SITE_ENABLED unset + # after provisioning the site destroys its Vercel project on apply. + TF_VAR_docs_site_enabled: "${{ vars.DOCS_SITE_ENABLED || secrets.DOCS_SITE_ENABLED || 'false' }}" + TF_VAR_docs_custom_domain: ${{ vars.DOCS_CUSTOM_DOMAIN || secrets.DOCS_CUSTOM_DOMAIN }} MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} diff --git a/.gitignore b/.gitignore index 4073e2ef29..3ce2826a79 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ venv/ dist/ build/ .next/ +packages/web/public/mermaid-renderer.html out/ # IDE @@ -44,6 +45,9 @@ yarn-error.log* # Test coverage/ +.vitest-reports/ +.coverage +.coverage.* .nyc_output/ # Cloudflare diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..a45fd52cc5 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.openinspect/setup.sh b/.openinspect/setup.sh index 111db50090..2493a70145 100755 --- a/.openinspect/setup.sh +++ b/.openinspect/setup.sh @@ -27,11 +27,11 @@ check_cmd node check_cmd npm NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]') -if (( NODE_MAJOR < 20 )); then - error "Node.js >= 20 required (found $(node -v)). Please upgrade." - exit 1 +if (( NODE_MAJOR < 24 )); then + warn "Node.js >= 24 required (found $(node -v)). Please upgrade; continuing setup." +else + info "Node $(node -v) ✓" fi -info "Node $(node -v) ✓" # --------------------------------------------------------------------------- # 2. Install npm dependencies (also triggers husky via prepare script) @@ -116,7 +116,9 @@ fi # 6. Verify the setup # --------------------------------------------------------------------------- info "Running type check…" -if npm run typecheck; then +if [ "${OPENINSPECT_BOOT_MODE:-}" = "fresh" ]; then + info "Skipping type check on a fresh session boot." +elif npm run typecheck; then info "Type check passed ✓" else warn "Type check had issues — you may need to build additional packages." diff --git a/.prettierignore b/.prettierignore index 3a6dea0f9d..bddcc25dfa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,6 +26,9 @@ pnpm-lock.yaml yarn.lock # Generated files +packages/docs/.source/ *.d.ts *.min.js *.min.css +packages/sandbox-runtime/src/sandbox_runtime/memory_contract.json +packages/sandbox-runtime/src/sandbox_runtime/tools/_memory-contract.js diff --git a/AGENTS.md b/AGENTS.md index 39acf6e0aa..055863a22e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ it at build time. | `shared` | TypeScript | Shared types, auth utilities, model definitions | | `control-plane` | TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration | | `web` | TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI | +| `docs` | TypeScript / Next.js 16 + Fumadocs | Public user documentation at docs.backgroundagents.dev | | `slack-bot` | TypeScript / CF Workers + Hono | Slack event handler, session creation | | `github-bot` | TypeScript / CF Workers + Hono | PR review and @mention webhook handler | | `linear-bot` | TypeScript / CF Workers + Hono | Linear agent webhook handler | @@ -63,6 +64,8 @@ npm run typecheck # tsc across all TS packages npm test -w @open-inspect/control-plane # unit tests (node env) npm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1) npm test -w @open-inspect/web +npm test -w @open-inspect/docs +npm run build -w @open-inspect/docs npm test -w @open-inspect/github-bot npm test -w @open-inspect/slack-bot npm test -w @open-inspect/linear-bot @@ -155,6 +158,9 @@ Pushing to `main` auto-deploys changed services: (triggers: `terraform/`, `packages/*/`) - **Vercel** → web app when `web_platform = "vercel"` (triggers: `packages/web/`, `packages/shared/`) +- **Vercel Docs** → public documentation is **not** auto-deployed; run the `Deploy Docs` workflow + manually on `main` (requires the dedicated docs project secrets described in + `packages/docs/README.md`) - **Modal** → data plane (triggers: `packages/modal-infra/`, deployed via Terraform apply) CI runs lint, typecheck, and tests for all TypeScript and Python packages on every push and PR. @@ -164,6 +170,7 @@ CI runs lint, typecheck, and tests for all TypeScript and Python packages on eve - [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — deploy your own instance - [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — detailed architecture and session lifecycle - [CONTRIBUTING.md](CONTRIBUTING.md) — contribution guidelines +- [docs/OPENCODE_LOCAL.md](docs/OPENCODE_LOCAL.md) — running OpenCode locally on this repo - [packages/control-plane/README.md](packages/control-plane/README.md) — API reference, WebSocket protocol, D1 schema, security model - [packages/modal-infra/README.md](packages/modal-infra/README.md) — sandbox internals, Modal diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9fe0d1a0..6b80d7e294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,402 @@ New features, integrations, and notable improvements to Open-Inspect — newest first. +## October 25, 2026 + +**Boat saved snapshot API.** Base builds, prebuilt image builds, and image cleanup now use Boat's +`/saved-snapshots` endpoints. Existing snapshots work without migration, and snapshot names and +Terraform bindings are unchanged. The next deployment rebuilds the base snapshot once because its +build inputs changed. + +**Shorter idle time for unattended sandboxes.** Automation runs, GitHub bot runs, and agent-created +child sessions now default to stopping their sandboxes after 2 minutes of inactivity. User, Slack, +and Linear sessions keep the 10-minute default. Operators can configure the shorter timeout with +`SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS`. A connected session page keeps the existing timer +extensions; direct terminal, VS Code, or VNC connections alone do not. Running turns stay active +through runtime heartbeats, and follow-up prompts resume the existing stopped session. + +**One-shot scheduled automations.** A schedule automation can now **Run once** at a future date and +time instead of following a cron expression. The automation stays enabled while its runs are active +or retrying, then disables itself. To run a paused one-shot whose time has passed, give it a new +future time and resume it. Pause or delete it to cancel a pending start. Requires D1 migration 0091. + +**Launch retries for scheduled runs.** Schedule automations can set **Retries** (none by default, up +to 5) to relaunch a run that failed before its prompt reached a session, for example during a D1 +stall. The first retry waits 2 minutes, and the wait doubles for each later attempt. Runs that fail +after starting, configuration errors such as a model the harness can't run, and **Trigger Now** runs +are never retried. A waiting run counts as active, and a run that uses up its retries counts as one +failure toward auto-pause. Requires D1 migration 0090. + +**Review memories in bulk.** **Settings › Memories** and **Settings › Shared memories** can select +up to 100 records and approve, reject, archive, or restore them in one action. Memories are never +hard-deleted, so bulk removal archives them or rejects proposals. Shared memories now open on an +**All** view across every repository and environment you can access. Each record links to its own +scope, and you pick a concrete scope before creating a memory. + +## October 24, 2026 + +**Codex on every sandbox provider.** The Codex harness is no longer limited to Modal and Modal VM; +Daytona, E2B, Vercel, and Boat deployments can run Codex sessions, automations, and child sessions +too. The harness now launches the image's pinned Codex CLI rather than whatever `codex` sits in +`/usr/local/bin`, which Boat replaces at boot. Rebuild your base image or snapshot if it predates +the Codex CLI, including a pinned `vercel_base_snapshot_id`. Prebuilt repository images older than +runtime v77 are rebuilt automatically; until then, Codex sessions start without them while other +harnesses keep using them. + +**Codex sessions report their cost.** Codex turns used to report a cost of zero. Each model request +is now priced from the models.dev catalog that the image already caches for OpenCode, so session +totals, analytics, and `maxSessionCostUsd` include Codex spend. A model missing from the image's +catalog is still reported as zero. + +**Mermaid diagrams in assistant messages.** Completed Mermaid code blocks in assistant replies +render as diagrams that follow the light or dark theme. The source stays available behind a +disclosure, and a diagram that fails to render falls back to code. + +**Duplicate an automation.** A **Duplicate** button on an automation's detail page opens the create +form filled in from that automation, named ` (copy)`. Webhook keys aren't copied, since new +keys are issued, and the Sentry client secret must be entered again. Environments you can't use are +left out, with a notice. + +## October 23, 2026 + +**Faster sandbox boots.** The runtime now fetches managed skills and session memory side by side +instead of one after the other. On Boat, the control plane re-reads a starting sandbox as soon as +the previous read has answered rather than every two seconds, watches the launcher for one second +instead of five after starting it, and names the sandbox while it hosts its ports rather than +before. Together these take a few seconds off every boot. Boat also now starts the runtime from the +create request itself, as Modal and Daytona do, instead of through a start command issued once the +control plane notices the machine is up; a resume still uses the start command. + +**Boat tunnel URLs reach the runtime again.** With the create-request launch, the tunnel env file +was written into the workspace before the machine was up, where Boat's snapshot mount hid it, and +every boot waited 30 seconds for a file it could not see before running `start.sh` without tunnel +URLs. The create now stages the file under `/tmp` and the setup script copies it into the workspace +before the launcher starts. + +**One runtime per Boat sandbox after a quick resume.** A resume soon after a stop can get the parked +machine back with its runtime still running. The launcher's single-instance lock lived under the +home directory, which Boat remounts when a lazy restore finishes, so the resumed launcher did not +see the lock the first boot held and started a second runtime. The two then took turns on the +session's connection every few seconds, and a turn could be reported complete twice. The lock now +lives in `/tmp`, so the resumed launcher finds the runtime running and leaves it be. + +**Fewer steps to start a Boat sandbox.** Boat's create request now takes the sandbox's name, its +visibility and the ports to route, so the control plane sends them with the create instead of +renaming, unsharing and hosting each port afterwards. A sandbox billed to an organization is private +before it has a machine, so it is never shared, not even for a moment. Boat also holds a file write +to a starting sandbox until the sandbox is ready, so the tunnel env file now goes straight into the +workspace instead of being staged under `/tmp` for the setup script to copy. + +## October 22, 2026 + +**Prebuilt images on Boat.** Repository and environment prebuilds now work on the Boat provider. A +build runs `setup.sh` once in a sandbox deployed from the base snapshot and its disk is saved as a +Boat named snapshot; sessions deploy from that image and skip setup. The build sandbox receives the +scope's secrets only after it is private, through a file no snapshot keeps, so an image never +carries them. Images count toward the account's named-snapshot allowance. See +[Pre-Built Images](docs/IMAGE_PREBUILD.md#boat-prebuilds). + +**Merge duplicate member accounts.** Owners and Administrators can merge two accounts that belong to +the same person from **Settings › Workspace access**, for example a Slack-created account alongside +a GitHub sign-in with a different email. This used to require running +`scripts/merge-split-users.ts`. A dry-run review lists everything that moves, changes, or is +discarded before you confirm, and the merge is recorded in the audit log. The new +`workspace.members.merge` permission can be granted to custom roles, which also need +`workspace.members.read` to review a merge. Merging Owner accounts requires an Owner. + +## October 20, 2026 + +**Boat sandbox provider.** [Boat](https://boat.dev) joins Modal, Daytona, Vercel, and E2B as a +selectable sandbox provider (`sandbox_provider = "boat"`). Sessions run in full Ubuntu VMs deployed +from a Terraform-built named snapshot; idle sessions are stopped (disk snapshotted, machine +released) and resume on a new machine with their files intact. Sessions pick the smallest Boat +machine size that fits their CPU and memory settings. See +[Boat Sandbox Provider](docs/BOAT_SANDBOX_PROVIDER.md). + +**OpenComputer sandbox provider removed.** The remaining providers are Modal, Modal VM, Daytona, +Vercel, E2B, and Boat. A deployment still set to `sandbox_provider = "opencomputer"` fails Terraform +validation, so switch providers before upgrading. The `opencomputer_*` Terraform variables and the +control plane's `ANTHROPIC_API_KEY` binding are gone; `anthropic_api_key` still feeds sandboxes and +the classifier bots. D1 migration 0089 deletes OpenComputer image build records. + +## October 19, 2026 + +**Autofix acts on approvals with inline comments.** An approved pull request review that starts at +least one inline comment thread now resumes the PR's owning session, so suggestions attached to an +approval get addressed. Approvals with only a summary or only thread replies are still skipped. If +you allowlist third-party review bots, expect more Autofix runs. + +**Exclude labels in PR automations.** The automation form's **Label** condition can now match **none +of** the chosen labels, so a review automation can skip pull requests carrying a given label. + +## October 15, 2026 + +**Rich child session status.** When an agent checks on its child sessions, the timeline shows each +child's linked metadata, status, and final response rendered as Markdown instead of raw text. The +full tool output stays available behind a disclosure. + +## October 14, 2026 + +**Codex harness.** Sessions can run on OpenAI's Codex as a third harness beside OpenCode and Claude +Agent. Choose it in the composer, in automations, and in the Slack, GitHub, and Linear harness +settings. Codex runs OpenAI models only and authenticates with an OpenAI API key or a ChatGPT +account connected under **Settings > Provider Accounts**; the legacy `OPENAI_OAUTH_*` secrets are +not used. A model, reasoning effort, or credential Codex can't use is rejected instead of being +replaced with a default. Some efforts, such as `max` on GPT-5.6 Sol, are available only on Codex. +Requires D1 migration 0087 and a sandbox image rebuild. + +**Mark all as read.** Hover the **Needs attention** heading in the sidebar or on a team overview +page to mark every unread session in it as read, including pages not yet loaded. Only sessions +matching the current filters are marked. + +**Skip draft pull requests in automations.** PR opened and PR updated automations can set **Draft** +to **Not draft** to skip drafts before a session starts. The new **PR ready for review** event runs +them when a draft is marked ready without a new push. See +[GitHub event triggers](docs/AUTOMATIONS.md#github-event-triggers). + +**Classify on Amazon Bedrock.** The Slack and Linear target classifiers can call Claude through +Amazon Bedrock. Set `classification_model` to `bedrock//` and +`classification_bedrock_api_key`; no Anthropic key is needed. Coding sessions are unchanged. See +[Classify on Amazon Bedrock](docs/GETTING_STARTED.md#classify-on-amazon-bedrock-optional). + +**Slack model and target safeguards.** A new Slack request whose model has been disabled is refused +with recovery steps in the thread instead of running on a different model, and App Home marks a +disabled personal model for replacement. When the classifier guesses a target, that guess is now the +first quick-pick button, and a guess of **No repository** always asks first, since a thread started +without a repository can't be retargeted. + +## October 13, 2026 + +**Continue a session from webhooks.** Inbound webhook payloads can include a `sessionKey`. Later +deliveries with the same key become follow-ups in the session the first one started, the way Slack +thread replies do. Follow-ups that arrive while that run is still starting are held and delivered in +order. If the session can't take a follow-up, the webhook returns an error instead of starting a new +session. The 7-day continuation window now counts from the session's last activity, for Slack +automations too. Requires D1 migration 0086. See +[Continuing a session](docs/AUTOMATIONS.md#continuing-a-session). + +**Markdown preview in the Changes panel.** Changed Markdown files get a **Preview** view beside +Unified and Split that renders the whole resulting document. + +**GitHub and Linear refuse incompatible launches.** When the configured harness can't run the +resolved model, the GitHub and Linear bots now reply with an explanation and create no session, as +Slack does, instead of falling back to OpenCode. A settings level that explicitly selects a +restricted harness needs a compatible default model at that same level. Existing settings without +one must be fixed before any other edits can be saved. See +[Choosing a harness](docs/CLAUDE_AGENT.md#choosing-a-harness). + +## October 12, 2026 + +**Slack replies render Markdown.** Agent replies in Slack now render headings, bold text, lists, and +links instead of showing raw Markdown. `@here` and `@channel` broadcasts are stripped from replies. + +**Search shared memories.** **Settings > Shared memories** has a search box covering titles, +descriptions, and content, and a searchable repository and environment picker. + +## October 8, 2026 + +**Claude Agent for Slack sessions.** Choose OpenCode or Claude Agent for new Slack sessions under +**Settings > Integrations > Slack**, and let each user override it in the Slack app's **Home** tab. +OpenCode stays the default. Claude Agent requires a Slack default model it can run. A request whose +model the harness cannot run is refused with advice in the thread instead of starting a session. On +Claude Agent, Slack sessions follow the Claude **Automated authentication** setting, so they can use +a connected Claude subscription. Existing threads keep their harness. See +[Claude Agent](docs/CLAUDE_AGENT.md#slack-sessions). + +## October 7, 2026 + +**Claude Agent for GitHub sessions.** Choose OpenCode or Claude Agent for GitHub-triggered sessions +under **Settings > Integrations > GitHub**, globally or per repository. OpenCode stays the default. +See [GitHub settings](docs/integrations/GITHUB.md#models-and-instructions). + +## October 5, 2026 + +**Burstable Modal VM resources.** `modal-vm` sandboxes reserve less CPU and memory by default and +can burst up to new `cpuLimitCores` and `memoryLimitMib` caps, set globally, per repository, or per +environment. Modal bills the larger of the request and actual usage, so lightly used VMs cost less. +Bursting depends on spare host capacity. Existing explicit requests are unchanged. See +[Modal VM setup](docs/MODAL_DOCKER.md#runtime-and-resources). + +## October 4, 2026 + +**GitHub model overrides.** Start a GitHub `@mention` with `!model` or `!reasoning`, the same flags +Slack uses, to pick the model and reasoning effort for that session. Upgrade the GitHub bot and +control plane together. See +[GitHub integration](docs/integrations/GITHUB.md#model-and-reasoning-overrides). + +**Claude Agent for Linear sessions.** Choose OpenCode or Claude Agent for Linear-triggered sessions +under **Settings > Integrations > Linear**, globally or per repository. Linear activities now name +the harness. On Claude Agent, Linear sessions follow the Claude **Automated authentication** +setting. See [Linear sessions](docs/CLAUDE_AGENT.md#linear-sessions). + +**Webhook invocation status.** Inbound webhook responses now include an `invocationId`. Check its +status and runs with the same webhook key at +`GET /webhooks/automation//invocations/`. A retry with the same +`idempotencyKey` returns the original ID. See [Inbound webhooks](docs/AUTOMATIONS.md#responses). + +**Autofix replies in review threads.** Autofix now replies to each inline review comment in its own +thread and resolves the thread once the comment is addressed, instead of posting one summary +comment. + +## October 3, 2026 + +**Persistent memory.** Personal, repository, and environment memories carry facts and directives +between sessions. Agents in both harnesses search, read, and propose memories. Only personal facts +written in a root session that has stayed private and owner-only become active immediately; all +other agent writes wait for approval. Manage personal memories under **Settings > Memories** and +repository and environment memories under **Settings > Shared memories**; each session's sidebar +lists the memories it loaded. Requires D1 migration 0084 and a sandbox image rebuild. See +[Persistent session memory](docs/MEMORY.md). + +## October 2, 2026 + +**Analytics redesign.** The analytics page is now an overview plus Usage, Cost, Pull requests, and +People tabs. The range, scope, and tab are kept in the URL. A new **Session origins** breakdown +shows where sessions start and who they are attributed to. + +**Saved prompt drafts.** Unsent prompts survive page reloads. Each session and the new-session +composer keep their own draft. + +**Teams.** Group members into teams that own sessions, environments, automations, and secrets, with +repository grants and Slack and Linear channel bindings managed from **Settings > Teams**. GitHub +work routes to teams by numeric repository ID: upgrade the GitHub bot and control plane together, +and reselect repositories on older GitHub event automations so they keep matching events. Sandbox +GitHub tokens now cover only the session's repositories. See +[Authentication and Authorization](docs/AUTH.md). + +**Classifier reasoning effort.** Set `classification_reasoning_effort` to send a reasoning effort to +OpenAI classification models used by the Slack and Linear bots. Leaving it blank keeps the model +default. + +## October 1, 2026 + +**Brokered credentials for Modal restores.** Restored Modal sandboxes now fetch git credentials from +the control plane like fresh ones, so Modal no longer needs the `github-app` secret. You can delete +it after upgrading. + +**Session page redesign.** The session sidebar is split into Info, Changes, Tasks, and Tools tabs, +with captured media under **Artifacts** in Info. Changed files open in the main column beside the +sidebar, which leaves room for split diffs. + +## September 28, 2026 + +**Claude Sonnet 5.5.** Adds `anthropic/claude-sonnet-5-5` to the model picker and integrations, with +adaptive thinking controls from low through max. + +**ChatGPT subscription cost estimates.** OpenCode sessions using a connected ChatGPT subscription +now report API-equivalent cost estimates in the session cost display and spending limit. + +## September 27, 2026 + +**Trace export schema 2.** Messages, events, and usage are now exported oldest first, and +single-session downloads include only the requested session. See the +[trace export reference](docs/TRACE_EXPORT.md). + +**Docker-capable Modal sandboxes.** Operators can select the `modal-vm` backend deployment-wide for +Docker support; `modal` remains the default. See [Modal VM setup](docs/MODAL_DOCKER.md). + +**Scoped analytics.** The analytics dashboard and API can filter human, agent, automation, or all +sessions, with breakdowns by model, harness, provider, and automation, token totals, cache hit +ratio, and cost per merged PR. + +## September 26, 2026 + +**Richer bulk session export.** Bulk export can now include each session's messages, timeline +events, and per-step token usage through the `include` parameter. + +## September 25, 2026 + +**Bulk export metadata and permission.** Exported session lines now include run identity, harness, +model provider, repositories, pull request lifecycle, and token totals. Bulk export now requires the +`sessions.export` permission, granted to Owners and Administrators by default. + +## September 23, 2026 + +**Sessions discovery across full history.** A new **Sessions** destination in the sidebar (above +Automations) searches every session the viewer may read, including archived work and history beyond +the command menu's recent window. Search matches titles, session-id prefixes, and any member +repository of a multi-repository session; creator, repository, environment, lifecycle, and +automation-origin filters compose on the server and live in the URL, so a filtered view can be +shared or reloaded. The command menu now labels its results as recent sessions and offers **Search +all sessions**, carrying typed text to the page; the sidebar's Archived shortcut opens the archived +view, with archive management still under Settings → Data Controls. The Inbox is unchanged. + +**Open changed files from agent output.** Repository file links in a session's agent output now open +matching files in the Changes panel, including renamed files and line references. Links to files +outside the session diff remain inert; external links retain their existing behavior. + +**Clearer audit log outcomes.** Authorization decisions in the workspace audit log now show +**Allowed** or **Denied** with the recorded HTTP response (for example, HTTP 409 Conflict) instead +of a green **Applied** badge, because admitting a request does not prove the change took effect. +**Applied**, **No change**, and **Rejected** are reserved for events recorded by the operation +itself. Older rows without a recorded status show the decision with the response marked as not +recorded; stored audit data is unchanged. + +**Claude Opus 5.5.** Adds `anthropic/claude-opus-5-5` to the model picker and integrations, with +adaptive thinking controls from low through max. The Claude Agent harness now uses +`claude-agent-sdk` 0.2.158, whose bundled Claude Code release natively supports Opus 5.5. + +## September 21, 2026 + +**Grok 4.7.** Adds `xai/grok-4.7` to the model picker and integrations, with reasoning efforts from +low through xhigh. `xai/grok-4.6` now also offers xhigh, which it supported but the catalog did not +expose. The **xAI / SuperGrok** group stays opt-in — enable the model under **Settings > Models**. +See [Available models](docs/AVAILABLE_MODELS.md#xai--supergrok). + +## September 20, 2026 + +**Graceful sandbox shutdown and recovery.** Sandboxes approaching their provider lifetime now stop +admitting new work, preserve the filesystem, and confirm retirement before expiry, reserving a +configurable **Final snapshot buffer** set globally or per repository and environment. Sessions show +shutdown and recovery progress: work queued at a clean prompt boundary restores automatically into a +new generation, while an interrupted prompt fails once and, after state is saved, queued work waits +for **Resume queued work** — the interrupted prompt is never replayed automatically. A failed or +uncertain shutdown stays held rather than falling back to a fresh checkout, offering retry or +restore only when the server authorizes it. + +**Provider-aware sandbox settings.** The per-session CPU, memory, and session-timeout controls are +now hidden when the configured provider cannot honor them. Daytona sandboxes inherit CPU and memory +from their snapshot and take no per-session lifetime, so all three are hidden with an explanation +while its image build timeout stays configurable, and stored values a provider cannot apply are +stripped before a session starts instead of blocking the spawn. + +## September 19, 2026 + +**Prebuilt images for Daytona.** Daytona joins the image-prebuild subsystem: it can build repository +and environment snapshots, start sessions from them, and reclaim sources and snapshots afterwards. +The feature is opt-in behind `DAYTONA_PREBUILDS_ENABLED` and off by default. Admission gates exactly +two things — starting a build and selecting a prebuilt image for a fresh session — so while it is +closed a manual rebuild returns 503, save hooks do nothing, and the cron skips new builds while +finalization, status, and cleanup keep running. None of this has been exercised against a live +Daytona deployment; the gates an operator should pass before opening admission are listed in +[Image prebuilds](docs/IMAGE_PREBUILD.md#verification-gates). + +**Slack model flags set session defaults.** A `!model` or `!reasoning` flag on the message that +opens a session now becomes that session's default for every following turn, instead of applying +only to the first prompt. Flags on a follow-up still apply to that one request. The "Starting +work..." acknowledgement names the resulting defaults whenever they differ from your App Home +preferences. + +## September 17, 2026 + +**Repository-less Slack sessions.** The Slack target classifier can start suitable work in an empty +sandbox, while uncertain requests use a target picker that also exposes **No repository**. Session +startup messages now use concise target-neutral copy. + +**Sandbox boot progress.** The sandbox runtime now connects to the control plane before it clones +the repository, so a session shows each boot step as it runs: Cloning repository, Running setup.sh, +Starting services, Installing skills, Starting agent, with the repository named in multi-repository +sessions. Failure reports retain phase, repository, and error metadata, while hook stdout and stderr +are discarded rather than collected or shown. Long setup scripts no longer trip the four-minute +connect timeout; a boot may take up to `SANDBOX_BOOT_TIMEOUT_MS` (30 minutes by default). See +[How Open-Inspect Works](docs/HOW_IT_WORKS.md#fresh-start-no-snapshot). + +## September 14, 2026 + +**OpenCode Go models.** Adds 27 opt-in `opencode-go/*` models to Settings > Models for OpenCode +sessions. Configure `OPENCODE_API_KEY` in Settings > Secrets with an active Go subscription. See +[Available models](docs/AVAILABLE_MODELS.md#opencode-go). + ## September 9, 2026 **Claude Fable 5.1.** Adds `claude-fable-5-1` to the model picker and integrations, with adaptive diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f600e80c2..3e9f8a33c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ This handles npm dependencies, builds the shared package, configures git hooks ( lint-staged), and optionally sets up a Python virtualenv for `packages/modal-infra`. See [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) for full deployment instructions. See -[docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md) for local setup and day-to-day development paths. +[docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md) for local setup and day-to-day development paths. To run +OpenCode itself on a checkout of this repo, see [docs/OPENCODE_LOCAL.md](docs/OPENCODE_LOCAL.md). For manual setup or individual steps: @@ -62,6 +63,27 @@ npm test - Run `npm run typecheck` to ensure type safety - Follow existing code patterns in the codebase +### Test Performance + +Run heavyweight validation commands (lint, typecheck, and full test suites) sequentially on a shared +development host. Each Vitest invocation sizes its own worker pool independently; running multiple +full suites together can exhaust a test's elapsed-time budget even when its assertions are correct. +If you intentionally overlap suites, pass an explicit `--maxWorkers` budget to each invocation, +accounting for the other work on the host. + +In DOM tests, scope queries to the relevant form section or open listbox. For example, +`within(screen.getByRole("listbox")).getByRole("option", { name })` avoids scanning unrelated +options, including Radix's hidden native selects, while preserving accessibility checks. Prefer +label queries when selecting labeled inputs. Investigate slow operations before increasing test +timeouts or adding retries. + +For coverage commands, the recorded baseline, and test-reduction tradeoffs, see +[Coverage-Guided Test Reduction](docs/TEST_REDUCTION.md). Control-plane coverage combines the Node +and workerd suites; unit-only coverage is not the measure of retained integration coverage. The +unfiltered `Coverage` workflow enforces production-only TypeScript and separate Python +statement/branch floors. Repository administrators should require its `Coverage` check in main +rules. + ### Commit Messages Use clear, descriptive commit messages: diff --git a/README.md b/README.md index 59f6be2de7..0398886426 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # Background Agents: Open-Inspect +[English](README.md) | [简体中文](README.zh-CN.md) + An open-source background agents coding system inspired by [Ramp's Inspect](https://builders.ramp.com/post/why-we-built-our-background-agent). +![Open-Inspect web UI: session sidebar and new session composer](docs/images/ui-overview.png) + ## Overview Open-Inspect provides a hosted background coding agent that can: @@ -22,32 +26,40 @@ Open-Inspect provides a hosted background coding agent that can: ## Security Model (Single-Tenant Only) > **Important**: This system is designed for **single-tenant deployment only**, where all users are -> trusted members of the same organization with access to the same repositories. +> trusted members of the same organization. Teams add internal access controls, not tenant +> isolation. ### How It Works -The system uses a shared GitHub App installation for git operations (clone, fetch, push). The -control plane mints short-lived installation tokens server-side and brokers them to sandboxes -through the git credential helper on demand. This means: - -- **Authorized users share the same GitHub App credentials** - The GitHub App must be installed on - your organization's repositories, and active users whose role permits repository use can access - any repo the App has access to -- **No per-user repository access validation** - The system does not verify that a user has - permission to access a specific repository before creating a session -- **GitHub users' OAuth tokens are used for PR creation** - For GitHub logins, PRs are created using - the user's GitHub OAuth token, ensuring proper attribution and that they can only create PRs on - repos they have write access to. Users who sign in another way (e.g. Google) carry no SCM token, - so their PRs fall back to the shared GitHub App bot - -### Token Architecture - -| Token Type | Purpose | Scope | -| ------------------ | -------------------------------------- | -------------------------------- | -| GitHub App Token | Brokered git clone/fetch/push auth | All repos where App is installed | -| User OAuth Token | Create PRs, user info | Repos user has access to | -| Sandbox Auth Token | Sandbox-to-control-plane session calls | Single session | -| WebSocket Token | Real-time session auth | Single session | +Teams own sessions, environments, and automations. A session's Workspace, Team, or Private +visibility is separate from its fixed ownership; resources cannot move between teams or to/from the +workspace. Team-owned session actions require current owning-team membership, even for workspace +Owners and Administrators. With enforcement on, deletion additionally requires `sessions.delete` and +being the session owner, an owning-team lead, or a workspace administrator; team-owned and private +sessions apply their action checks in every mode. + +Private sessions are readable by their owner and explicit collaborators. Workspace Owners have an +audited break-glass read path, not automatic collaboration or sandbox access. Team visibility's read +boundary requires `TEAMS_ENFORCEMENT=on`; the deployment default remains `shadow`. + +For a single-team deployment, create one team, add the users who need to act on its sessions, and +grant its repositories. Existing workspace-owned rows are not moved or hidden. Even with every user +in one team, being a Member does not let someone delete another member's session under enforcement +unless they satisfy the ownership/lead rule. Follow the +[first-team setup](docs/GETTING_STARTED.md#step-10-create-the-first-team-and-test-a-session) before +inviting users. + +The shared GitHub App installation bounds workspace repository reach. Sandbox git credentials are +limited to the session's persisted repositories and, for team-owned sessions, current owning-team +grants. Workspace permissions and resource access still apply; Open-Inspect does not compare a +user's personal GitHub repository permissions before creating a session. GitLab credentials remain a +deployment-wide PAT rather than a per-session token. + +Credentials are brokered on demand but cached on disk, so snapshots can retain them. Grant removal +does not immediately revoke issued tokens. See the canonical +[Authentication and Authorization guide](docs/AUTH.md) for access rules and +[credential boundaries](docs/AUTH.md#repository-and-credential-boundaries), including user versus +App identity and snapshot limitations. ### Why Single-Tenant Only @@ -121,19 +133,19 @@ ownership, bots, and member suspension. ## Packages -| Package | Description | -| ------------------------------------------------- | ------------------------------------------- | -| [control-plane](packages/control-plane) | Cloudflare Workers + Durable Objects | -| [web](packages/web) | Next.js web client | -| [sandbox-runtime](packages/sandbox-runtime) | Shared in-sandbox agent runtime | -| [modal-infra](packages/modal-infra) | Modal sandbox infrastructure | -| [daytona-infra](packages/daytona-infra) | Daytona snapshot infrastructure | -| [e2b-infra](packages/e2b-infra) | E2B sandbox template infrastructure | -| [opencomputer-infra](packages/opencomputer-infra) | OpenComputer template infrastructure | -| [slack-bot](packages/slack-bot) | Slack integration (sessions from messages) | -| [github-bot](packages/github-bot) | GitHub integration (auto-review, @mention) | -| [linear-bot](packages/linear-bot) | Linear integration (issue → coding session) | -| [shared](packages/shared) | Shared types and utilities | +| Package | Description | +| ------------------------------------------- | ------------------------------------------- | +| [control-plane](packages/control-plane) | Cloudflare Workers + Durable Objects | +| [web](packages/web) | Next.js web client | +| [sandbox-runtime](packages/sandbox-runtime) | Shared in-sandbox agent runtime | +| [modal-infra](packages/modal-infra) | Modal sandbox infrastructure | +| [daytona-infra](packages/daytona-infra) | Daytona snapshot infrastructure | +| [e2b-infra](packages/e2b-infra) | E2B sandbox template infrastructure | +| [boat-infra](packages/boat-infra) | Boat sandbox snapshot infrastructure | +| [slack-bot](packages/slack-bot) | Slack integration (sessions from messages) | +| [github-bot](packages/github-bot) | GitHub integration (auto-review, @mention) | +| [linear-bot](packages/linear-bot) | Linear integration (issue → coding session) | +| [shared](packages/shared) | Shared types and utilities | ## Getting Started @@ -207,19 +219,14 @@ await configureGitIdentity({ Choose the AI model that fits your task, with per-session reasoning effort controls: -| Provider | Models | -| ---------------- | ----------------------------------------------------------------------- | -| Anthropic | Claude Haiku 4.5, Sonnet 4.5/4.6/5, Opus 4.5/4.6/4.7/4.8/5, Fable 5/5.1 | -| OpenAI | GPT 5.4, GPT 5.5, 5.3 Codex, 5.3 Codex Spark | -| xAI / SuperGrok | Grok models (opt-in) | -| OpenCode Zen | Kimi K2.5/K2.6/K3, MiniMax M2.5, Qwen3.7 Max, GLM 5/5.1/5.2 (opt-in) | -| Z.AI Coding Plan | GLM 5.2/5.3 (opt-in) | +Anthropic and OpenAI models are enabled by default. xAI / SuperGrok, OpenCode Zen and Go, Z.AI +Coding Plan, and DeepSeek models are opt-in. See [Available Models](docs/AVAILABLE_MODELS.md) for +current model IDs, descriptions, and reasoning efforts. OpenAI models work with your existing ChatGPT subscription via OAuth — no separate API key needed. Anthropic models can run on the **Claude Agent** harness with a connected Claude subscription; see [Using the Claude Agent Harness](docs/CLAUDE_AGENT.md). Grok models work with an eligible SuperGrok subscription through control-plane-managed OAuth. See -**[docs/AVAILABLE_MODELS.md](docs/AVAILABLE_MODELS.md)** for the full model list and **[docs/OPENAI_MODELS.md](docs/OPENAI_MODELS.md)** or **[docs/GROK_MODELS.md](docs/GROK_MODELS.md)** for subscription setup instructions. @@ -257,7 +264,7 @@ See **[docs/AUTOMATIONS.md](docs/AUTOMATIONS.md)** for setup instructions. Every session runs in an isolated sandbox backend with a full development environment: -- **Pre-installed:** Node.js 22, Python 3.12, Bun, git, GitHub CLI, build-essential +- **Pre-installed:** Node.js 24, Python 3.12, Bun, git, GitHub CLI, build-essential - **Browser automation:** agent-browser CLI with headless Chromium for screenshots, visual diffs, and UI verification - **Code-server:** Optional browser-based VS Code connected to the session workspace @@ -300,12 +307,24 @@ docker compose up -d postgres redis - `setup.sh` failures are non-fatal for fresh sessions, but fatal in image build mode - `start.sh` runs for every non-build session startup (fresh, prebuilt-image, snapshot-restore) - `start.sh` failures are strict: if present and it fails, session startup fails -- Default timeouts: - - `SETUP_TIMEOUT_SECONDS` (default `300`) - - `START_TIMEOUT_SECONDS` (default `120`) +- Open-Inspect does not impose hook-specific timeouts. Scripts remain subject to the boot budget + (`SANDBOX_BOOT_TIMEOUT_MS`, 30 minutes by default, measured across the whole session boot), to + enclosing sandbox shutdown and image-build limits, and can apply their own command-specific + deadlines when needed. +- Each script's progress is reported to the session while it runs. Failure reports retain the phase, + repository when available, and error or warning metadata, but hook stdout and stderr are discarded + rather than collected or shown. Image builds report neither, having no session to report to. See + [How Open-Inspect Works](docs/HOW_IT_WORKS.md#fresh-start-no-snapshot) - Both hooks receive `OPENINSPECT_BOOT_MODE` (`build`, `fresh`, `repo_image`, `snapshot_restore`) -- Git operations in hooks can authenticate to other private repos on the configured SCM host when - the shared installation has access +- GitHub session credentials used by hooks are limited to the session's persisted repository set, + intersected with current owning-team grants for team-owned sessions. Private submodules, + Git-backed dependencies, and sibling clones must be included in that set; App installation access + alone does not expand it +- GitHub image-build credentials cover only the build repositories, with owning-team grants applied + for team-owned environment builds. GitLab uses a deployment-wide PAT without per-session + narrowing. See + [Repository and Credential Boundaries](docs/AUTH.md#repository-and-credential-boundaries) for + details, including credential caching and grant revocation ## License @@ -319,8 +338,8 @@ built with: - [Modal](https://modal.com) - Cloud sandbox infrastructure - [Daytona](https://www.daytona.io) - Cloud development sandboxes - [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) - Cloud sandbox infrastructure -- [OpenComputer](https://www.opencomputer.dev) - Cloud sandbox infrastructure - [E2B](https://e2b.dev) - Cloud sandbox infrastructure +- [Boat](https://boat.dev) - Cloud sandbox infrastructure - [Cloudflare Workers](https://workers.cloudflare.com) - Edge computing - [OpenCode](https://opencode.ai) - Coding agent runtime (built-in harness) - [Claude Agent SDK](https://docs.anthropic.com/en/docs/agent-sdk) - Coding agent runtime (Claude diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000000..c6e5dbf636 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,302 @@ +# Background Agents: Open-Inspect + +[English](README.md) | [简体中文](README.zh-CN.md) + +一个开源的后台编程智能体系统,灵感来自 +[Ramp 的 Inspect](https://builders.ramp.com/post/why-we-built-our-background-agent)。 + +> 本文是英文 README 的简体中文译本。贡献翻译时,请同步核对英文原文,保留命令、配置名称及链接目标。下方链接指向的详细文档目前为英文。修改任一 README 后,可运行 +> `node --test scripts/readme-translations.test.mjs` +> 检查章节结构、代码示例和链接是否一致。这些检查不能替代对译文准确性和内容同步情况的人工审阅。 + +![Open-Inspect 网页界面:会话侧边栏与新会话输入区](docs/images/ui-overview.png) + +## 概述 + +Open-Inspect 提供托管的后台编程智能体,可以: + +- 在后台处理任务,让你专注于其他工作 +- 使用完整的开发环境(Node.js、Python、git、浏览器自动化、VS Code) +- 通过网页界面、Slack、GitHub PR、Linear 议题或 Webhook 随时接入 +- 支持多人实时协作的会话 +- 创建 PR,并将提交正确归属于发送提示的用户 +- 运行基于 cron 的定时自动化,或由 GitHub 事件、Sentry 告警和 Webhook 触发的自动化 +- 启动并行子任务,在独立沙箱中同时工作 +- 自由选择 AI 模型:Anthropic Claude(通过 API 密钥,或在 Claude + Agent 执行框架中连接 Claude 订阅)、OpenAI Codex(通过 ChatGPT 订阅)、xAI + Grok(通过 SuperGrok 订阅)或 OpenCode Zen + +## 安全模型(仅支持单租户) + +> **重要**:本系统仅为**单租户部署**设计,所有用户都应是同一组织中受信任的成员。团队提供组织内部的访问控制,不提供租户隔离。 + +### 工作方式 + +团队拥有会话、环境和自动化。会话的工作区(Workspace)、团队(Team)或私有(Private)可见性与其固定的归属关系相互独立;资源不能在团队之间迁移,也不能在团队与工作区之间迁移。操作团队所属的会话需要当前仍是该团队的成员,即使是工作区所有者(Owner)和管理员(Administrator)也不例外。启用强制执行后,删除操作还要求具有 +`sessions.delete` +权限,并且操作者是会话所有者、所属团队负责人或工作区管理员;团队所属会话和私有会话在所有模式下都会检查操作权限。 + +私有会话仅对其所有者和明确添加的协作者可读。工作区所有者可通过有审计记录的应急通道读取,但不会自动获得协作或沙箱访问权限。团队可见性的读取边界需要设置 +`TEAMS_ENFORCEMENT=on`;部署默认值仍为 `shadow`。 + +对于单团队部署,请先创建一个团队,添加需要操作其会话的用户,并授权访问相应仓库。已有的工作区所属记录不会被迁移或隐藏。即使所有用户都在同一团队,启用强制执行后,普通成员(Member)仍不能删除其他成员的会话,除非满足所有权或负责人规则。邀请用户之前,请先完成 +[首个团队的设置](docs/GETTING_STARTED.md#step-10-create-the-first-team-and-test-a-session)。 + +共享的 GitHub +App 安装范围限定了工作区能够访问的仓库。沙箱 git 凭据仅限于会话持久化记录中的仓库;对于团队所属会话,还会受到该团队当前仓库授权的限制。工作区权限和资源访问规则仍然适用;Open-Inspect 在创建会话前不会核对用户个人的 GitHub 仓库权限。GitLab 凭据仍使用整个部署共享的个人访问令牌(PAT),而非每个会话独立的令牌。 + +凭据按需获取,但会缓存在磁盘上,因此快照可能保留凭据。移除授权不会立即撤销已签发的令牌。访问规则以[身份认证与授权指南](docs/AUTH.md)为准;另请参阅 +[仓库与凭据边界](docs/AUTH.md#repository-and-credential-boundaries),了解用户身份与 App 身份的区别以及快照限制。 + +### 为什么仅支持单租户 + +本架构遵循 +[Ramp 的 Inspect 设计](https://builders.ramp.com/post/why-we-built-our-background-agent),面向内部使用场景,假定所有员工都受信任且能够访问公司仓库。 + +**如果要进行多租户部署**,则需要: + +- 为每个租户单独安装 GitHub App +- 在创建会话时验证访问权限 +- 在数据模型中实现租户隔离 + +### 部署建议 + +1. **部署在组织的 SSO/VPN 之后**:确保只有获授权的员工能够访问网页界面 +2. **仅在目标仓库上安装 GitHub App**:App 的安装范围决定系统能够访问哪些仓库 +3. **限制登录**:配置允许登录的 GitHub 用户、邮箱域名或有效的 GitHub 组织成员资格(`ALLOWED_GITHUB_ORGS`) +4. **使用 GitHub 的仓库选择功能**:安装 App 时选择特定仓库,而不是“All repositories” + +有关工作区角色、会话访问、自动化归属、机器人和成员停用的说明,请参阅[身份认证与授权](docs/AUTH.md)。 + +## 架构 + +``` + ┌──────────────────┐ + │ Clients │ + │ ┌──────────────┐ │ + │ │ Web / Slack │ │ + │ │ GitHub / Lin.│ │ + │ │ Webhooks │ │ + │ └──────────────┘ │ + └────────┬─────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ Control Plane (Cloudflare) │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Durable Objects (per session) │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │ │ +│ │ │ SQLite │ │WebSocket│ │ Event │ │ GitHub │ │ │ +│ │ │ DB │ │ Hub │ │ Stream │ │ Integration │ │ │ +│ │ └─────────┘ └─────────┘ └─────────┘ └───────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ D1 Database (repo-scoped secrets) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ Data Plane (Sandbox Backend) │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Session Sandbox │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ Supervisor│──│ Harness │──│ Bridge │─────────────────┼──┼──▶ Control Plane +│ │ └───────────┘ │ (OpenCode │ └───────────┘ │ │ +│ │ │ or Claude)│ │ │ +│ │ └───────────┘ │ │ +│ │ │ │ │ +│ │ Full Dev Environment │ │ +│ │ (Node.js, Python, git, agent-browser) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +## 软件包 + +| 软件包 | 说明 | +| ------------------------------------------- | ------------------------------------ | +| [control-plane](packages/control-plane) | Cloudflare Workers + Durable Objects | +| [web](packages/web) | Next.js 网页客户端 | +| [sandbox-runtime](packages/sandbox-runtime) | 沙箱内共享的智能体运行时 | +| [modal-infra](packages/modal-infra) | Modal 沙箱基础设施 | +| [daytona-infra](packages/daytona-infra) | Daytona 快照基础设施 | +| [e2b-infra](packages/e2b-infra) | E2B 沙箱模板基础设施 | +| [boat-infra](packages/boat-infra) | Boat 沙箱快照基础设施 | +| [slack-bot](packages/slack-bot) | Slack 集成(从消息创建会话) | +| [github-bot](packages/github-bot) | GitHub 集成(自动审查、@提及) | +| [linear-bot](packages/linear-bot) | Linear 集成(议题 → 编程会话) | +| [shared](packages/shared) | 共享类型与工具函数 | + +## 入门 + +本地开发、参与贡献和部署的实用设置指南,请从 **[docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md)** 开始。 + +部署说明请参阅 **[docs/GETTING_STARTED.md](docs/GETTING_STARTED.md)**。 + +如需了解架构与核心概念,请阅读 **[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**。 + +如需设置周期性定时任务,请参阅 **[docs/AUTOMATIONS.md](docs/AUTOMATIONS.md)**。 + +如需创建和使用可复用的智能体指令,请参阅 **[docs/MANAGED_SKILLS.md](docs/MANAGED_SKILLS.md)**。 + +## 核心功能 + +### 快速启动 + +通过多层预热机制,会话几乎可以即时启动: + +- **文件系统快照**:每次提示处理结束后保存沙箱状态;后续会话从快照恢复,无需重新克隆仓库 +- **预构建镜像**:可按仓库(Settings > Images)或按环境(Settings > + Environments)启用;每 30 分钟使用最新提交和依赖重新构建 +- **主动预热**:你开始输入时沙箱便开始启动,无需等到按下回车键 + +### 多仓库会话与环境 + +一个会话可以在同一沙箱中处理多个仓库: + +- **临时组合**:在新会话选择器中选择最多 10 个仓库;仓库并排克隆,智能体可以协调修改并为每个仓库创建 PR +- **环境**:将一组仓库保存为命名环境,配置独立的密钥作用域和可选的预构建镜像,然后像选择普通仓库一样从选择器启动 +- 环境模型请参阅 [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#environments),环境预构建请参阅 + [docs/IMAGE_PREBUILD.md](docs/IMAGE_PREBUILD.md) + +### 托管技能 + +创建可复用的指令和配套文件,供智能体在会话启动时使用: + +- 将共享技能分配到全局,或指定的仓库和环境 +- 将常用技能组合保存为个人配置 +- 为每个会话固定具体的技能修订版本,确保行为可复现 +- 使用指南请参阅 [docs/MANAGED_SKILLS.md](docs/MANAGED_SKILLS.md) + +### 多人协作会话 + +多个用户可以在同一个会话中协作: + +- 在线状态指示器显示当前活跃用户 +- git 提交归属于发送相应提示的用户 +- 向所有已连接的客户端实时推送内容 + +### 提交归属 + +提交归属于发送提示的用户: + +```typescript +// Configure git identity per prompt +await configureGitIdentity({ + name: author.scmName, + email: author.scmEmail, +}); +``` + +### 多提供商模型支持 + +为任务选择合适的 AI 模型,并按会话控制推理强度: + +Anthropic 和 OpenAI 模型默认启用。xAI / SuperGrok、OpenCode Zen 和 Go、Z.AI Coding +Plan 以及 DeepSeek 模型需要手动启用。当前的模型 ID、说明和推理强度选项请参阅[可用模型](docs/AVAILABLE_MODELS.md)。 + +OpenAI 模型可通过 OAuth 使用你现有的 ChatGPT 订阅,无需单独的 API 密钥。Anthropic 模型可以在 +**Claude Agent** +执行框架中使用已连接的 Claude 订阅;请参阅[使用 Claude Agent 执行框架](docs/CLAUDE_AGENT.md)。Grok 模型可通过控制平面管理的 OAuth 使用符合条件的 SuperGrok 订阅。订阅设置说明请参阅 +**[docs/OPENAI_MODELS.md](docs/OPENAI_MODELS.md)** 或 +**[docs/GROK_MODELS.md](docs/GROK_MODELS.md)**。 + +### 客户端集成 + +通过团队日常使用的工具与智能体交互: + +- **网页界面**:完整的会话管理,支持实时内容推送、模型和推理强度选择器、终端面板及多人在线状态 +- **Slack 机器人**:通过 @提及或私信启动会话,可在提示中附加 PNG、JPEG、WebP 和 GIF;结果回复到消息线程。可在 App + Home 中设置个人模型和分支偏好。请参阅 [Slack 集成](docs/integrations/SLACK.md) +- **GitHub 机器人**:在 PR 创建时自动审查,或响应 PR 评论中的 @提及。可按仓库配置。请参阅 + [GitHub 集成](docs/integrations/GITHUB.md) +- **Linear 机器人**:在议题中提及智能体或将议题分配给它,即可启动编程会话、发布进度并关联最终的 PR。请参阅 + [Linear 集成](docs/integrations/LINEAR.md) +- **Webhook**:通过经过身份认证的 HTTP POST,从任何外部系统触发会话 + +### 自动化 + +定时执行周期性任务或响应外部事件,无需人工介入: + +- **Cron 定时计划**:支持每小时、每天、每周、每月或自定义五字段 cron 表达式,以及时区设置 +- **Sentry 告警**:自动排查新错误、回归问题和关键指标告警 +- **GitHub 工作流运行**:在 GitHub Actions 工作流结束时启动任务,可按工作流名称和执行结果筛选 +- **入站 Webhook**:通过 JSONPath 条件筛选,控制哪些请求载荷会创建会话 +- **多仓库分发**:一个定时自动化最多可覆盖 10 个仓库,为每个仓库创建独立的会话和 PR +- 连续失败 3 次后自动暂停,支持手动触发和完整的运行历史记录 + +设置说明请参阅 **[docs/AUTOMATIONS.md](docs/AUTOMATIONS.md)**。 + +### 沙箱环境 + +每个会话都在隔离的沙箱后端中运行,具备完整的开发环境: + +- **预装工具**:Node.js 24、Python 3.12、Bun、git、GitHub CLI、build-essential +- **浏览器自动化**:agent-browser CLI 配合无头 Chromium,用于截图、视觉差异比较和界面验证 +- **Code-server**:可选的浏览器版 VS Code,连接到会话工作区 +- **网页终端**:通过会话界面访问基于 ttyd 的终端 +- **端口隧道**:通过加密隧道暴露最多 10 个开发服务器端口。在 `.openinspect/start.sh` + 运行前,沙箱内的 `/workspace/.tunnels.env` + 已包含相关 URL([详情](docs/HOW_IT_WORKS.md#tunnel-urls-inside-the-sandbox)) +- **密钥**:使用 AES-256-GCM 加密,可配置全局、仓库或环境作用域,在创建沙箱时作为环境变量注入。支持批量粘贴导入 + `.env` + +### 子会话 + +智能体可以将工作拆分为并行的子会话: + +- `spawn-child` 在独立沙箱中创建子会话并立即返回 +- 子会话在各自分支上并行运行,父会话可以继续工作 +- `send-child-prompt` 向已有的直接子会话排队发送后续指令 +- `get-child-status` 和 `cancel-child` 用于协调子会话 +- 系统会执行嵌套深度限制和每个仓库的保护规则 + +### 仓库生命周期脚本 + +仓库可以在 `.openinspect/` 下定义两个可选的启动脚本: + +```bash +# .openinspect/setup.sh (provisioning) +#!/bin/bash +npm install +pip install -r requirements.txt +``` + +```bash +# .openinspect/start.sh (runtime startup) +#!/bin/bash +docker compose up -d postgres redis +``` + +- `setup.sh` 在构建镜像和全新会话时运行 +- 从预构建镜像或快照恢复启动时跳过 `setup.sh` +- 全新会话中 `setup.sh` 失败不会阻止启动,但在镜像构建模式下会导致构建失败 +- `start.sh` 在每次非构建模式的会话启动时运行(全新启动、预构建镜像启动、快照恢复) +- `start.sh` 严格处理失败:如果脚本存在且执行失败,会话启动也会失败 +- Open-Inspect 不为这些钩子单独设置超时。脚本仍受整个会话启动预算(`SANDBOX_BOOT_TIMEOUT_MS`,默认为 30 分钟)、外层沙箱关闭限制和镜像构建限制约束;必要时也可以在脚本中为具体命令设置截止时间 +- 脚本运行期间会向会话报告进度。失败报告会保留执行阶段、可用的仓库信息以及错误或警告元数据,但钩子的标准输出和标准错误会被丢弃,不会收集或显示。镜像构建没有对应会话,因此两者都不报告。请参阅 + [Open-Inspect 的工作原理](docs/HOW_IT_WORKS.md#fresh-start-no-snapshot) +- 两个钩子都会收到 `OPENINSPECT_BOOT_MODE`(`build`、`fresh`、`repo_image`、`snapshot_restore`) +- 钩子使用的 GitHub 会话凭据仅限于会话已持久化的仓库集合;团队拥有的会话还会与所属团队当前的仓库授权取交集。私有子模块、基于 Git 的依赖和同级仓库克隆所涉及的仓库必须包含在该集合中;仅有 App 安装的访问权限不会扩大此集合 +- GitHub 镜像构建凭据仅覆盖构建所涉及的仓库;团队拥有的环境构建还受所属团队仓库授权的限制。GitLab 使用部署级 PAT,不按会话缩小权限范围。关于凭据缓存和授权撤销等详情,请参阅[仓库与凭据边界](docs/AUTH.md#repository-and-credential-boundaries) + +## 许可证 + +MIT + +## 致谢 + +灵感来自 +[Ramp 的 Inspect](https://builders.ramp.com/post/why-we-built-our-background-agent),并使用以下技术构建: + +- [Modal](https://modal.com):云沙箱基础设施 +- [Daytona](https://www.daytona.io):云端开发沙箱 +- [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox):云沙箱基础设施 +- [E2B](https://e2b.dev):云沙箱基础设施 +- [Boat](https://boat.dev):云沙箱基础设施 +- [Cloudflare Workers](https://workers.cloudflare.com):边缘计算 +- [OpenCode](https://opencode.ai):编程智能体运行时(内置执行框架) +- [Claude Agent SDK](https://docs.anthropic.com/en/docs/agent-sdk):编程智能体运行时(Claude + Agent 执行框架) +- [Next.js](https://nextjs.org):Web 框架 diff --git a/docker-compose.aws.yml b/docker-compose.aws.yml index a4f2c87003..1c70217775 100644 --- a/docker-compose.aws.yml +++ b/docker-compose.aws.yml @@ -5,14 +5,16 @@ # Three differences from the base file, and nothing else. The instance has no # checkout, so the app runs the image built and pushed by CI rather than a # build context. Object storage is S3 and the credentials come from the -# instance role, so MinIO does not run. TLS is not optional on a public -# address, so Caddy leaves the "tls" profile and starts with the rest. +# instance role, so the local object store does not run. TLS is not optional +# on a public address, so Caddy leaves the "tls" profile and starts with the +# rest. # # The `.env` this reads is written by the instance's user-data from SSM -# Parameter Store. It still has to carry MINIO_ROOT_PASSWORD, OBJECT_STORE_BUCKET -# and LITESTREAM_BUCKET: Compose interpolates the base file before it applies -# this one, so their `:?` guards fire whether or not the services that use them -# are started. The AWS `.env` gives MinIO's an unused value. +# Parameter Store. It still has to carry OBJECT_STORE_ROOT_PASSWORD, +# OBJECT_STORE_BUCKET and LITESTREAM_BUCKET: Compose interpolates the base file +# before it applies this one, so their `:?` guards fire whether or not the +# services that use them are started. The AWS `.env` gives the local object +# store's password an unused value. services: app: @@ -20,20 +22,18 @@ services: # resetting `build` is what makes `up` pull it instead of trying to build. build: !reset null image: ${CONTROL_PLANE_IMAGE:?CONTROL_PLANE_IMAGE must name the ECR image to run, including its tag} - # The base file waits for MinIO's buckets to be created. On S3 the buckets - # are Terraform's, and nothing here creates them. + # The base file waits for the local object store's buckets. On S3 the + # buckets are Terraform's, and nothing here creates them. depends_on: !reset null # Not started on AWS: S3 is the object store and the Litestream replica # target. A profile no environment enables is how a service is left out of # a merged stack; there is no way to delete one in an overlay. - minio: - profiles: ["local-object-store"] - minio-init: + object-store: profiles: ["local-object-store"] litestream: - # The base file also waits for MinIO here. + # The base file also waits for the local object store here. depends_on: !override app: condition: service_healthy diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml index 88472dd65f..2829a12333 100644 --- a/docker-compose.smoke.yml +++ b/docker-compose.smoke.yml @@ -28,13 +28,12 @@ services: ports: !override - "127.0.0.1:${SMOKE_APP_PORT:-8787}:8787" - minio: + object-store: ports: !override - - "127.0.0.1:${SMOKE_MINIO_PORT:-9000}:9000" - - "127.0.0.1:${SMOKE_MINIO_CONSOLE_PORT:-9001}:9001" + - "127.0.0.1:${SMOKE_OBJECT_STORE_PORT:-9000}:9000" fake-modal: - image: node:22-slim + image: node:24-slim # The repository is the working directory so `ws` resolves from the root # node_modules the CI job already installed. working_dir: /repo diff --git a/docker-compose.yml b/docker-compose.yml index d221c1b98d..ffa0d63436 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,17 @@ -# The control plane without Cloudflare: the Node host, MinIO for media and -# backups, and a Litestream sidecar replicating the global store. This is -# the stack the AWS instance runs and the local stand-in for it. The web app -# is not part of it (it stays on Vercel; run `next dev` locally). +# The control plane without Cloudflare: the Node host, an S3-compatible +# object store for media and backups, and a Litestream sidecar replicating +# the global store. This is the stack the AWS instance runs and the local +# stand-in for it. The web app is not part of it (it stays on Vercel; run +# `next dev` locally). # # cp .env.example .env # then fill it in # docker compose up --build # # Only the app reads .env; the sidecars get the few variables they need by # name. The app's port is published on APP_BIND_ADDRESS (loopback unless -# .env says otherwise) and MinIO's ports on loopback only, so nothing here -# listens on a public interface by default. See docs/CONTROL_PLANE_CONTAINER.md. +# .env says otherwise) and the object store's on loopback only, so nothing +# here listens on a public interface by default. See +# docs/CONTROL_PLANE_CONTAINER.md. services: app: @@ -33,48 +35,74 @@ services: volumes: - control-plane-data:/data depends_on: - minio-init: - condition: service_completed_successfully + object-store: + condition: service_healthy restart: unless-stopped - minio: - image: minio/minio:RELEASE.2025-04-22T22-12-26Z - command: server /data --console-address ":9001" + # SeaweedFS as the S3-compatible store for media and backups, pinned by + # digest to the multi-arch index so amd64 CI and arm64 laptops run the same + # release. Credentials are required on every S3 request. + # + # `weed server` runs every component in this one container. Only the S3 + # gateway listens beyond it; the master, volume server and filer bind to + # loopback, since nothing outside the container talks to them. Each start + # generates the filer signing key the components share. The buckets named + # in S3_BUCKET are created once S3 answers. + # + # The shell stays as PID 1 to create the buckets, and forwards SIGTERM to + # the server. The filer can take 10 s to close its streams, so the stop + # grace period outlasts it. + object-store: + image: chrislusf/seaweedfs:4.47@sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882 + entrypoint: ["/bin/sh", "-c"] + command: + - | + WEED_JWT_FILER_SIGNING_KEY="$$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + export WEED_JWT_FILER_SIGNING_KEY + /entrypoint.sh server -ip=127.0.0.1 -ip.bind=127.0.0.1 -s3.ip.bind=0.0.0.0 \ + -master.peers=none -master.telemetry=false -volume.preStopSeconds=1 \ + -filer -s3 -s3.port=9000 -s3.port.iceberg=0 -s3.port.lance=0 & + server=$$! + trap 'kill -TERM "$$server"' TERM INT + until curl -sf -o /dev/null http://localhost:9000/healthz; do + kill -0 "$$server" 2>/dev/null || exit 1 + sleep 1 + done + IFS=, + for bucket in $$S3_BUCKET; do + echo "s3.bucket.create -name $$bucket" | weed shell -master=127.0.0.1:9333 >/dev/null + done + wait "$$server" || wait "$$server" + stop_grace_period: 20s environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set; generate one with openssl rand -hex 16} - # Loopback only: the app and the sidecar reach MinIO over the compose - # network, and the console is a local convenience. + AWS_ACCESS_KEY_ID: ${OBJECT_STORE_ROOT_USER:-objectstoreadmin} + AWS_SECRET_ACCESS_KEY: ${OBJECT_STORE_ROOT_PASSWORD:?OBJECT_STORE_ROOT_PASSWORD must be set; generate one with openssl rand -hex 16} + S3_BUCKET: ${OBJECT_STORE_BUCKET:?OBJECT_STORE_BUCKET must name the media bucket},${LITESTREAM_BUCKET:?LITESTREAM_BUCKET must name the backup bucket} + # Loopback only: the app and the sidecar reach it over the compose network. ports: - "127.0.0.1:9000:9000" - - "127.0.0.1:9001:9001" volumes: - - minio-data:/data + - object-store-data:/data + # Healthy once S3 answers and every bucket exists, so the app and + # Litestream never start against a store without its buckets. The filer is + # asked, over loopback, because S3 answers an unsigned request 403 whether + # or not the bucket exists. Checks run every 2 s until the first passes, + # then every 30 s; an engine older than Docker 25 skips the 2 s phase. healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 5s + test: + - CMD-SHELL + - >- + curl -sf -o /dev/null http://localhost:9000/healthz && + IFS=, && for bucket in $$S3_BUCKET; do + curl -sf -o /dev/null "http://localhost:8888/buckets/$$bucket/" || exit 1; + done + interval: 30s timeout: 3s - retries: 12 + retries: 3 + start_period: 60s + start_interval: 2s restart: unless-stopped - # Creates the media and backup buckets once, then exits. It sees only the - # variables named here, never the app's secrets. - minio-init: - image: minio/mc:RELEASE.2025-04-16T18-13-26Z - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set; generate one with openssl rand -hex 16} - MEDIA_BUCKET: ${OBJECT_STORE_BUCKET:?OBJECT_STORE_BUCKET must name the media bucket} - BACKUP_BUCKET: ${LITESTREAM_BUCKET:?LITESTREAM_BUCKET must name the backup bucket} - depends_on: - minio: - condition: service_healthy - entrypoint: ["/bin/sh", "-c"] - command: - - | - mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" && - mc mb --ignore-existing "local/$$MEDIA_BUCKET" "local/$$BACKUP_BUCKET" - litestream: image: litestream/litestream:0.3.13 # The app's user, so the shadow WAL it writes beside the database is @@ -92,7 +120,7 @@ services: depends_on: app: condition: service_healthy - minio: + object-store: condition: service_healthy restart: unless-stopped @@ -117,6 +145,6 @@ services: volumes: control-plane-data: - minio-data: + object-store-data: caddy-data: caddy-config: diff --git a/docs/AUTH.md b/docs/AUTH.md index e8d9f4fc84..4687bad6d5 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -1,12 +1,14 @@ # Authentication and Authorization Open-Inspect uses authentication to establish who you are and workspace authorization to decide what -you can do. This guide explains the behavior users and workspace administrators will see. +you can do. This is the canonical guide to security, resource access, and credential boundaries; +other guides summarize these rules for their audiences. > **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one -> workspace, and the source-control App installation defines the repositories available to that -> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository -> access lists. +> workspace. The GitHub App installation bounds GitHub repository reach; GitLab uses a deployment +> PAT. Roles control which Open-Inspect features a person can use; teams and session visibility +> further limit access to resources. Neither is a replacement for source-control repository +> permissions. --- @@ -44,84 +46,279 @@ Open-Inspect includes four built-in roles. | Use repositories and environments in sessions | Yes | Yes | Yes | No | | Manage shared settings, integrations, and secrets | Yes | Yes | No | No | | Create sessions | Yes | Yes | Yes | No | -| View every session | Yes | Yes | Yes | Yes | -| Collaborate in and manage sessions | Yes | Yes | Yes | No | +| View sessions allowed by visibility | Yes | Yes | Yes | Yes | +| Collaborate in and manage permitted sessions | Yes | Yes | Yes | No | | View automations | Yes | Yes | Yes | Yes | | Create automations | Yes | Yes | Yes | No | | Manage and trigger own automations | Yes | Yes | Yes | No | | Manage and trigger any automation | Yes | Yes | No | No | -| View and manage workspace members | Yes | Yes | No | No | +| View workspace members | Yes | Yes | No | No | +| Manage workspace members | Yes | Yes | No | No | +| Merge duplicate member accounts | Yes | Yes | No | No | | Transfer workspace ownership | Yes | No | No | No | | View analytics | Yes | Yes | Yes | Yes | | View provider accounts | Yes | Yes | Yes | No | | View image-build history | Yes | Yes | Yes | Yes | | Manage personal skill profiles | Yes | Yes | Yes | No | +These are workspace feature permissions, not unconditional resource access. Team membership, lead +authority, ownership, and session visibility add the checks described below. + ### Owner -Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend -and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or -demoted, so the workspace cannot accidentally lose all ownership. +Owners administer the workspace but do not automatically collaborate in other people's private +sessions. Only Owners can grant or remove the Owner role or suspend and restore another Owner. +Open-Inspect also prevents the final active Owner from being suspended or demoted, so the workspace +cannot accidentally lose all ownership. ### Administrator -Administrators can operate the workspace day to day. They can manage members, sessions, automations, -repositories, environments, provider accounts, integrations, and secrets. They cannot transfer +Administrators can operate the workspace day to day. They can manage members, permitted sessions, +automations, repositories, environments, provider accounts, integrations, and secrets. They cannot +access another person's private session unless added as a collaborator. They cannot transfer ownership, change who holds the Owner role, or suspend and restore an Owner. ### Member -Members can create and use sessions, collaborate in existing sessions, use shared repositories and -environments, and create automations. They can manage and manually trigger automations they own but -cannot modify another person's automation or administer shared configuration. They can view -workspace analytics. +Members can create and use sessions, collaborate in sessions they can access, use shared +repositories and environments, and create, manage, and manually trigger automations they execute. +Team leads can also manage their team's membership, grants, secrets, and automations; leading a team +does not grant workspace-wide configuration permissions. Members can view workspace analytics. ### Viewer -Viewers have read-only access to shared workspace resources. They can inspect sessions, automations, -analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt -sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared -configuration. - -## How Session Access Works - -Sessions are workspace resources rather than private resources owned by their creator. - -- Anyone with session read access can view every session in the workspace. -- Anyone with collaboration access can prompt and contribute to every session. -- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every - session. -- Anyone with sandbox access can use supported sandbox tools for every session. -- Anyone with delete access can delete every session. - -The creator shown on a session records attribution; it is not an access list. Likewise, participant -labels identify who contributed to a session but do not grant or remove workspace permissions. The -**Mine** filter is a convenience for finding sessions you created, not a security boundary. - -Creating a session also requires permission to use its selected repository or environment. A role -may therefore be able to view an existing session without being allowed to create a new one. - -New HTTP requests reflect role changes and suspension immediately. Live browser connections to a -session are rechecked at least every five minutes, so a connection may remain open for up to five -minutes after access changes. Recreating the session is not required. +Viewers have read-only access to shared workspace resources. They can inspect sessions visible to +them, automations, analytics, repositories, environments, skills, and MCP servers. They cannot +create or prompt sessions, access sandboxes, manage personal skill profiles, trigger automations, or +change shared configuration. + +## Teams and Session Visibility + +Teams are optional. A team has members and leads, an open or invite-only join policy, and a default +session visibility (`team` or `workspace`). Owners and Administrators create teams in **Settings > +Teams**, and the creator becomes the first lead. Existing and teamless sessions stay workspace-owned +(`ownerTeamId: null`); creating a team does not move them. Team membership does not replace the +workspace role: a person still needs the relevant workspace permission. + +Any active workspace user can join an open team; invite-only teams require a lead or workspace +Owner/Administrator to add members. Leads and workspace Owners/Administrators manage membership, +lead roles, team metadata, and archive/restore. Members can leave with **Remove** on their own +membership row. The last lead cannot leave, be removed, or be demoted. + +### Team Directory and Pages + +Every active workspace user can list active teams and their members. Archived teams are visible only +to their members and workspace Owners and Administrators. Email addresses in the team directory and +collaborator picker are shown only to viewers with `workspace.members.read`. + +A team's session overview is available to its members and workspace Owners and Administrators, still +subject to session visibility. Team tabs appear according to the viewer's capabilities. Team +operations are recorded in the workspace audit log, which `workspace.audit.read` holders can filter +by any team. Sidebar team selections only narrow which readable sessions are listed. The new-session +composer's team and visibility, by contrast, set the created session's ownership and audience. + +### Session Visibility + +Each session stores a visibility independently of its owning team, so a team-owned session can be +team-visible, workspace-visible, or explicitly private. + +| Visibility | Who can read the session when team enforcement is on | +| ----------- | ---------------------------------------------------------------------------------------------- | +| `workspace` | Workspace users with session read permission, even if the session has a team. | +| `team` | Members of the owning team, plus workspace Owners and Administrators. Requires an owning team. | +| `private` | The session owner and explicit collaborators, plus audited Owner break-glass reads by ID. | + +All rows also require session read permission. + +- **Private sessions are enforced in every mode.** An Owner's break-glass read is audited, does not + list the session, and does not grant prompt or sandbox access unless the Owner becomes a + collaborator. With the relevant permissions, the Owner can still manage the session's lifecycle, + deletion, collaborators, and visibility (with owning-team membership if it is team-owned). + Administrators have no break-glass exception, and actorless bot services cannot read private + sessions. +- **Team-owned sessions require current team membership for every non-read action** in every mode, + including for session owners, team leads, Owners, and Administrators. Visibility grants reads + only. The one exception is a collaborator removing themselves, which needs only read access. +- **Collaborators** are a private-session access grant, not a team membership or role change. Only + the session owner or a workspace Owner can add or remove other collaborators. On a team-owned + session, collaborators must be current team members (others are rejected with `not_team_member`), + and leaving the team ends their access. +- **Session owners and participants differ.** A session's owner is its creator, not its team, and + can read it even when private (with session read permission). Runtime participants and the + **Mine** filter are attribution and discovery only; they do not grant or limit access. + +Each action also needs its own permission: prompting needs collaboration, sandbox use needs sandbox +access, and lifecycle operations need lifecycle permission. Deleting a team-owned session, or any +session when enforcement is `on`, also requires session ownership, owning-team lead status, or a +workspace Owner/Administrator role. Only the session owner or a workspace Owner can change private +visibility. Leading a team does not grant access to its private sessions. + +### Team Defaults and Migration + +A team's default visibility accepts only `team` or `workspace` and sets the audience of new +sessions, not their ownership. `private` remains an explicit per-session choice. Migration `0085` +changes existing private team defaults to `team` without changing existing sessions, and adds +triggers that reject private defaults. **Apply migration `0085` before deploying the matching +application code**; otherwise existing private defaults can fail validation. + +### Creating Sessions + +Creating a team session requires active membership in that team and a grant covering **every** +session repository; archived teams cannot be selected. Without an explicit visibility, sessions from +any launch source (including Slack, Linear, and automations) use the owning team's current default, +and teamless sessions use `workspace`. `team` visibility requires a team. Agent-spawned children of +a team-owned session require the active prompt author to still be a team member and fail with +`not_member` otherwise; they never borrow the parent owner's membership. + +**Settings > Teams > Require a team for new sessions** (`requireTeamOnCreate`, off by default) +requires a team for new sessions, environments, and automations, including bot-created sessions. +Requests without one fail with `team_required`. Existing workspace-owned resources are unaffected, +and existing workspace-owned automations still run and create workspace-owned sessions. + +Team leads and workspace Owners/Administrators manage repository grants in the team's Repositories +tab. A team has either installation-wide access or named grants by repository ID, and no grants by +default. Repository-backed team sessions without covering grants fail with +`target_team_missing_grant`; repository-less team sessions need no grants. Removing a grant narrows +future GitHub sandbox credentials but does not revoke issued tokens; GitLab's deployment PAT is +never narrowed. + +Repository skills, secrets, and image builds remain workspace-level resources. Once any team grants +a repository, using them requires membership in an active granting team (lead for repository +secrets) or a workspace Owner/Administrator role, in every enforcement mode. An installation-wide +grant counts as granting every repository. + +### Environment Access + +Team-owned environments require owning-team membership or a workspace Owner/Administrator role to +read or use. Managing any environment, including its secrets, settings, and images, requires +`environments.manage` in addition to the specific feature permission, plus lead status or a +workspace Owner/Administrator role for team-owned environments. Manual image builds also require the +owning team's grants to cover every repository. Environment names are unique within their owning +team or the workspace. + +A team environment can launch only into sessions owned by the same team. A team's session catalog +can also include workspace environments whose repositories its grants cover. These checks apply in +every `TEAMS_ENFORCEMENT` mode. + +### Ownership and Discovery + +Sessions, automations, and environments cannot move between teams or between a team and the +workspace. Changing a team session's visibility to `workspace` changes who can read it, not its +ownership or the membership required to act on it. Visibility changes that include child sessions +refuse the whole request if any child is inaccessible. + +Session lists and filters (`ownerFilter`, `visibility`, `teamIds[]`, `scope`) only narrow the +sessions a user can already read. `scope=workspace` means teamless sessions, not workspace +visibility. `scope=all` is reserved for Owners and Administrators and does not list break-glass-only +private sessions. + +### Enforcement and Access Paths + +Operators set `TEAMS_ENFORCEMENT` to `off`, `shadow` (the default), or `on`. `off` keeps legacy read +access to non-private sessions and legacy actions on non-private workspace-owned sessions; `shadow` +does the same while auditing would-be denials; `on` enforces team visibility and action rules. +Terraform exposes this as `teams_enforcement`, resolved in CI from the repository variable, then the +same-named secret, then `shadow`. The AWS configuration sets `shadow` in its `config` map; change +`TEAMS_ENFORCEMENT` there instead. Deploying Teams alone does not enable `on`. + +No mode relaxes private-session access, current membership for team-owned actions, checks on +visibility and collaborator changes, or environment, automation, repository-grant, and team-secret +checks. + +Session access is enforced on four paths: + +- **HTTP item routes** authorize against the stored session and return a non-enumerating `404` for + hidden sessions. +- **Lists and aggregates**, including search, inbox, bulk export, and analytics, filter by + visibility before returning results. Administrative analytics can include an unattributed total + cost of private sessions without exposing the sessions. +- **Durable Object connections** recheck subscriptions and commands against the current session. +- **Sandbox access** is a separate action; being able to read a session does not grant its sandbox. + +New HTTP requests reflect role, membership, collaborator, and visibility changes immediately. Live +browser connections are rechecked at least every five minutes, so one may remain open for up to five +minutes after access changes. + +### Reviewing Shadow Denials + +Before switching `TEAMS_ENFORCEMENT` from `shadow` to `on`, review the requests `on` would have +denied. Shadow records are observation only and are written only in `shadow` mode: + +- HTTP item routes record `authorization.request_allowed` with `shadow_denied:`. +- Session lists, inbox reads, child lists, and bulk exports record one `shadow_denied:batch` row per + request, with the hidden-row count in `metadata_json.shadowDenialCount`. +- WebSocket reads record `session.shadow_denied` at most once per connection, session, and reason. + +Analytics aggregates are not observed, and audit writes are best effort. In the audit log, WebSocket +records show **Would deny**; HTTP records show **Allowed** with a `shadow_denied:*` reason. For +daily counts by path and reason, run this query against the D1 `authorization_audit_events` table, +replacing the start date with your shadow rollout date: + +```sql +WITH shadow AS ( + SELECT id, date(occurred_at / 1000, 'unixepoch') AS day, + CASE WHEN action = 'session.shadow_denied' THEN 'websocket' + WHEN reason_code = 'shadow_denied:batch' + AND json_extract(metadata_json, '$.httpMethod') = 'GET' + THEN 'http_list' + ELSE 'http_item' END AS seam, + reason_code, metadata_json + FROM authorization_audit_events + WHERE occurred_at >= unixepoch('2026-10-01') * 1000 + AND reason_code LIKE 'shadow_denied:%' + AND action IN ('authorization.request_allowed', 'session.shadow_denied') +), reasons AS ( + SELECT id, day, seam, substr(reason_code, 15) AS reason + FROM shadow WHERE reason_code != 'shadow_denied:batch' + UNION + SELECT id, day, seam, json_extract(metadata_json, '$.shadowDenialReason') AS reason + FROM shadow + WHERE reason_code = 'shadow_denied:batch' + AND json_extract(metadata_json, '$.shadowDenialReason') IS NOT NULL + UNION + SELECT s.id, s.day, s.seam, json_extract(d.value, '$.reason') AS reason + FROM shadow s, json_each(s.metadata_json, '$.shadowDenials') d + WHERE s.reason_code = 'shadow_denied:batch' + UNION + SELECT id, day, 'http_item', json_extract(metadata_json, '$.shadowReason') + FROM shadow + WHERE reason_code = 'shadow_denied:batch' + AND json_extract(metadata_json, '$.shadowReason') IS NOT NULL +) +SELECT day, seam, reason, COUNT(*) AS would_be_denied_requests +FROM reasons +GROUP BY day, seam, reason +ORDER BY day, seam, reason; +``` + +`not_member` results cover any viewer outside the owning team, including users who belong to no +team. Counts are affected requests or WebSocket connections, not hidden sessions or unique users. ## How Automation Access Works -Automation definitions and run history are visible workspace-wide to roles with automation read -access. Creating, changing, and manually triggering automations use ownership rules. +Automations have a fixed owning team (or workspace ownership) and a separate executor, initially the +creator. Reading a team automation requires team membership or a workspace Owner/Administrator role +in every enforcement mode. Creating any automation requires both `automations.create` and +`sessions.create`. -- Members can manage and manually trigger automations they own. -- Administrators and Owners can manage and manually trigger any automation. -- Viewers can inspect automations but cannot create, change, or run them. +- Executors and owning-team leads can manage and trigger eligible automations with the `own` + permissions; `any` permissions extend this to all eligible automations. +- Administrators and Owners can manage and manually trigger any automation, but manually running a + team automation still requires membership in its owning team. +- Viewers can inspect eligible automations but cannot create, change, or run them. -Automation ownership follows the signed-in account that created it, not a display name or external -provider username. +Team leads and workspace Owners/Administrators can reassign the executor to another authorized user; +reassignment is audited. See [executor reassignment](AUTOMATIONS.md#executor-reassignment). Reading +an automation does not grant access to its sessions, and run history redacts sessions the viewer +cannot read. ### Scheduled and Event Runs -Scheduled and event-driven runs execute under the automation owner's authority. At run time, the -owner must still be active and allowed to create sessions and use every selected repository or -environment. If those permissions have been removed, the run does not start. +Scheduled and event-driven runs execute under the executor's authority. At run time, the executor +must still be active and allowed to create sessions and use the selected targets. Team runs also +require current team membership and grants covering their repositories, and their sessions use the +team's default visibility; workspace automations create workspace-visible sessions. If authorization +fails, the run does not start. ### Manual Runs @@ -148,6 +345,35 @@ Some integrations also apply their own ingress rules. For example, the GitHub in require an allowed trigger user or sufficient repository collaborator access before it sends a request to Open-Inspect. +### Slack and Linear Bindings + +Team leads and workspace Owners/Administrators bind Slack channels and Linear teams in **Teams > +Channels**, so requests from them create sessions owned by that team. Each channel or Linear team +belongs to at most one Open-Inspect team. Bindings do not add members or grants, and changing one +does not reassign existing sessions. Each integration's `unboundChannels` setting in **Settings > +Integrations** either creates workspace sessions from unbound channels (`workspace`, the default) or +rejects them (`reject`). + +In every enforcement mode, actorless bot reads scoped to an unbound channel or Linear team see only +workspace-owned, non-private sessions, so unbinding immediately revokes access to that team's +sessions. Slack never posts private sessions, even when the acting user can read them, or posts +team-owned sessions to a channel not bound to that team; confirmed publication denials close the +thread without session content, while a follow-up refused for one user does not close it for others. +Linear withholds completion results if the issue has moved to another Linear team. Its actorless +reads, including completion reads, otherwise follow `TEAMS_ENFORCEMENT`: full Team-visibility +isolation requires `on`. See [Slack](integrations/SLACK.md) and [Linear](integrations/LINEAR.md). + +### GitHub Routing + +GitHub routes by numeric repository ID rather than channel bindings. Event automations run as their +executor for their owning team, which must still hold a grant for the repository. Mentions use the +linked PR session's team, then a team of the sender that holds a grant (broken by their most recent +session when several qualify), then workspace ownership when neither resolves. Routing never +bypasses session-creation checks or the require-team policy. Deprecated auto-review-on-open remains +workspace-owned; use a team-owned GitHub Event automation instead. See +[GitHub](integrations/GITHUB.md), including +[upgrade steps](integrations/GITHUB.md#upgrading-to-repository-id-routing) for existing deployments. + ## Suspension Suspending a member disables their workspace access without deleting their account or historical @@ -158,16 +384,45 @@ After suspension: - New browser and bot operations are denied. - Existing browser sign-in sessions are invalidated. - Live browser session connections close within five minutes. -- Scheduled and event-driven automations owned by the member no longer pass run authorization. +- Scheduled and event-driven automations using the member as executor no longer pass run + authorization. - Existing session history and authorship remain intact. -Suspension does not automatically stop a sandbox that is already executing. An Administrator or -Owner can manage that session separately. +Suspension does not automatically stop a sandbox that is already executing. A user with the required +session action access can manage it separately; Administrators and Owners still need owning-team +membership for team-owned session actions. ## Repository and Credential Boundaries -Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations. -The App should be installed only on repositories intended for the workspace. +Open-Inspect uses a shared GitHub App installation for GitHub clone, fetch, and push operations. The +App should be installed only on repositories intended for the workspace. GitHub sandbox tokens cover +only the session's repositories, further limited to the owning team's grants for team-owned +sessions. Private submodules, dependencies, and sibling clones must be included in the session +before it starts. Unresolvable repositories fail closed rather than falling back to +installation-wide access. + +Removing a grant does not revoke tokens already issued; they remain valid until expiry. GitLab uses +a deployment-wide PAT that is not scoped per session. Teams do not establish multi-tenant isolation. +See [Sandbox Repository Access](GETTING_STARTED.md#sandbox-repository-access). + +### Credential Delivery and Snapshots + +Sandboxes fetch git credentials on demand from the control plane through the `oi-git-credentials` +helper, rather than receiving a clone token at launch. The helper caches the credential on disk +(mode `0600`) until shortly before expiry, and a cached credential can be used without a new +authorization check. + +Modal snapshots capture the full sandbox filesystem without clearing that cache, so a snapshot can +contain and restore cached tokens, along with any credentials written by setup scripts or the agent. +On GitLab the cached credential is the deployment-wide PAT, which stays valid beyond the cache +lifetime. Treat snapshots as sensitive artifacts. + +Image builds receive `VCS_CLONE_TOKEN` instead, scoped on GitHub to the repository or the +environment's repositories (intersected with the owning team's grants); GitLab builds receive the +deployment PAT. Files written during a build can persist in prebuilt images; see +[Secrets and Prebuilt Images](SECRETS.md#secrets-and-prebuilt-images). + +### User Credentials and Secrets A user's role determines whether they may read or use workspace repositories, but Open-Inspect does not compare that role with the user's personal GitHub access for each repository. Linked GitHub @@ -195,8 +450,7 @@ active Owner cannot be suspended or demoted. The first person who signs in receives the default Member role and is not promoted to Owner automatically. On a new deployment, the intended Owner must sign in once, after which a deployment operator runs the Owner bootstrap command using that person's Open-Inspect user ID. See -[Getting Started](GETTING_STARTED.md#step-7a-bootstrap-the-workspace-owner) for the deployment -steps. +[Getting Started](GETTING_STARTED.md#step-9-bootstrap-the-workspace-owner) for the deployment steps. ## Related Guides diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index deca7bd89c..b85ee5a0f2 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -1,6 +1,6 @@ # Automations -Automations let you run coding agents either on a recurring schedule or when an external event +Automations let you run coding agents on a schedule (once or recurring) or when an external event arrives. Define the repository configuration, model, and instructions once, then Open-Inspect starts a new session whenever the trigger fires. @@ -8,7 +8,7 @@ Trigger types: | Trigger Type | Description | Availability | | ------------------- | ----------------------------------------- | ------------------ | -| **Schedule** | Run on a cron schedule | Available | +| **Schedule** | Run once or on a cron schedule | Available | | **Inbound Webhook** | Trigger from any system with an HTTP POST | Available | | **Sentry Alert** | Trigger from a Sentry Custom Integration | Available | | **Slack Message** | Trigger on messages in watched channels | Available | @@ -24,8 +24,22 @@ new Sentry issues, and recurring report generation. Navigate to **Automations** in the sidebar, then click **Create Automation**. +Creation requires both `automations.create` and `sessions.create`, plus permission to use the +selected targets. The creator becomes the initial executor. The web creation and template entry +points also require both permissions; automation creation permission alone is insufficient. + Start by choosing a **Trigger Type**. The rest of the form adjusts based on that choice. +Choose workspace ownership or an owning team; team pages also expose an **Automations** tab. The +creator must belong to the selected active team, and that team's grants must cover the selected +repositories, including repositories resolved from environment targets. Newly created teams have no +repository grants. When `requireTeamOnCreate` is enabled, new automation definitions must have a +team. Existing workspace automations are not migrated or blocked from running by that setting alone. + +Ownership is fixed at creation: an automation cannot move between teams or between a team and the +workspace. Its **executor**, initially the creator, is a separate user identity that can be +reassigned without changing ownership. + ### Required Fields | Field | Description | @@ -48,7 +62,7 @@ Start by choosing a **Trigger Type**. The rest of the form adjusts based on that | Trigger Type | Additional Fields | | ------------------- | -------------------------------------------------------------------------------------------- | -| **Schedule** | **Schedule** and **Timezone** | +| **Schedule** | **Schedule** (**Recurring** or **Run once**), **Timezone** (recurring only), and **Retries** | | **Inbound Webhook** | No extra required fields | | **Sentry Alert** | **Event Type** and **Sentry Client Secret** | | **GitHub Event** | **Event Type** and optional **Conditions** | @@ -90,7 +104,9 @@ deployments, monitoring systems, scheduled jobs, and custom integrations. 2. Copy the generated webhook URL and API key shown after creation. 3. Send an authenticated HTTP `POST` request with a JSON body. 4. Open-Inspect prepends a webhook context block to your automation instructions and starts a new - session if the request matches the automation's conditions. + session if the request matches the automation's conditions. A request with a `sessionKey` can + continue the session an earlier request started instead (see + [Continuing a Session](#continuing-a-session)). ### Setup Notes @@ -190,28 +206,61 @@ shown to the agent. If you do not provide an `idempotencyKey`, each webhook delivery gets its own concurrency key. That means separate deliveries for the same automation can run at the same time. +### Continuing a Session + +Include a `sessionKey` string in the JSON body to send several deliveries to one session. The first +delivery with a key starts a run as usual. A later delivery with the same key is queued as a +follow-up prompt on that run's session instead of starting a run, whether the run is still starting, +working, or finished, as long as the session was active within the last 7 days. The follow-up +carries the payload's context block without the saved instructions, and it is sent as the +automation's executor, who must be able to collaborate on the session; without that access the +delivery is skipped. + +Conditions still apply to follow-ups. The `sessionKey` stays in the stored body, is removed from the +context block, and must be a non-empty string of at most 256 characters, or the request is rejected +with `400`. Pair it with an `idempotencyKey`: the session recognizes a retried follow-up and the +retry counts as skipped. A follow-up that arrives while the first run is still starting is held and +queued right after the run's own first prompt. If the session will not take a follow-up (archived, +cancelled, out of budget, needing sandbox recovery, or unreachable), the request fails with `409`, +`422`, `429`, or `503` instead of starting over in a new session. After 7 days without activity in +the session, the next delivery starts a new run. + ### Responses Successful requests return JSON in this shape: ```json -{ "ok": true, "triggered": 1, "skipped": 0 } +{ "ok": true, "triggered": 1, "skipped": 0, "steered": 0, "invocationId": "3f2a…" } ``` -`triggered` is the number of automation runs started. +`triggered` is the number of automation runs started, and `steered` the number of follow-ups routed +into an existing session by a `sessionKey`. `invocationId` identifies the firing this request +belongs to (for a repeated `idempotencyKey`, the original firing, and for a follow-up, the firing of +the run whose session took it), or is `null` when nothing was recorded. Read its status with the +same API key at `GET /webhooks/automation//invocations/`, which +returns `{ invocationId, status, runs: [{ id, status, sessionId }] }` and never session content. -`skipped` is the number of matching runs that were ignored because of duplicate delivery or -concurrency protection. +`skipped` includes runs not started because of duplicate delivery, concurrency protection, or +runtime authorization denial. Authorization denial does not pause an event-driven automation or +record a run-history invocation; restore the required executor/team/target access before sending +another delivery. ### Error Responses -| Status | Meaning | -| ------ | ------------------------------------------------ | -| `400` | Invalid JSON body | -| `401` | Missing or invalid API key | -| `404` | Automation not found or not a webhook automation | -| `413` | Payload too large | -| `415` | `Content-Type` was not `application/json` | +| Status | Meaning | +| ------ | ----------------------------------------------------------------------------------------------------------------------- | +| `400` | Invalid JSON body, or an invalid `sessionKey` | +| `401` | Missing or invalid API key | +| `404` | Automation not found or not a webhook automation | +| `409` | The `sessionKey` session cannot take prompts: `SESSION_NOT_PROMPTABLE`, `BUDGET_EXHAUSTED`, `SANDBOX_RECOVERY_REQUIRED` | +| `413` | Payload too large | +| `415` | `Content-Type` was not `application/json` | +| `422` | The `sessionKey` session rejected the follow-up for another reason; a retry is rejected the same way | +| `429` | The `sessionKey` session's queue is full (`PROMPT_QUEUE_FULL`) | +| `503` | The `sessionKey` session could not be reached (`SESSION_UNAVAILABLE`); safe to retry with the same `idempotencyKey` | + +A refused follow-up returns `{ "error", "code", "invocationId" }`, where `invocationId` is the run +whose session refused it, and starts nothing. --- @@ -220,8 +269,8 @@ concurrency protection. A **GitHub Event** automation starts a session when a supported webhook event arrives for its repository. Pick one repository and one event type. The GitHub App must subscribe to that event and have its required repository permission. Set `enable_github_bot = true`, then complete the -[GitHub bot setup](GETTING_STARTED.md#step-7c-complete-github-bot-setup-if-using-github-bot) to -deploy the webhook worker and configure event delivery. +[GitHub bot setup](GETTING_STARTED.md#github-bot-optional) to deploy the webhook worker and +configure event delivery. **Workflow Run Completed** handles completed GitHub Actions workflow runs. It requires the GitHub App's read-only Actions permission and the **Workflow runs** event subscription. Use these @@ -236,14 +285,19 @@ ID in the untrusted event context sent to the agent. When GitHub supplies them, includes the branch, commit SHA, workflow path, and run URL. Open-Inspect includes the attempt number in its deduplication key, so it can admit each GitHub rerun once. +**Draft** matches on the pull request's draft state. Set it to **Not draft** to skip draft pull +requests before Open-Inspect starts a session. Pair it with a **PR Ready for Review** automation, +which fires when the author marks a draft pull request ready, so the pull request still gets a run +when it leaves draft without a new push. + **Conclusion** also works for completed check suites. **Check Conclusion** remains available for existing check-suite automations. Each event type offers only the conditions its payload can answer, so the choices change with the event you pick — pull requests offer branch, target branch, label, and actor; issues offer label and -actor; comments offer actor. Changing the event type removes conditions the new one cannot answer, -and the form says which. GitHub webhook payloads carry no file list, so path-pattern filtering is -not offered for any GitHub event. +actor; comments offer actor. PR opened and PR updated also offer **Draft**. Changing the event type +removes conditions the new one cannot answer, and the form says which. GitHub webhook payloads carry +no file list, so path-pattern filtering is not offered for any GitHub event. --- @@ -305,14 +359,15 @@ the message needs nothing from you, reply with exactly `NO_REPLY` and nothing el agent will answer everything it is woken for unless told otherwise. A run that opened a pull request or produced other artifacts always posts. -Every reply in a thread **continues the same session** — during the run and after it finishes — for -up to 7 days after the thread's first trigger, exactly like replying in an `@mention` thread. The -reply is enqueued as a follow-up turn on that session (re-spawning it from a snapshot if it had gone -idle), and the agent posts its response in-thread when the turn finishes. A follow-up does not need -to match the trigger condition — conditions gate new runs, not replies that continue an existing -thread. If a reply races the very first trigger before its session exists, it falls back to an -ephemeral "a run is already active" notice (reason `concurrent_run_active`); a reply more than 7 -days after the first trigger starts a fresh run. +Every reply in a thread **continues the same session** — while the run starts, during it, and after +it finishes — until the session has been idle for 7 days, exactly like replying in an `@mention` +thread. The reply is enqueued as a follow-up turn on that session (re-spawning it from a snapshot if +it had gone idle), and the agent posts its response in-thread when the turn finishes. A reply that +arrives while the run is still starting is held and queued right after the run's own first prompt. A +follow-up does not need to match the trigger condition — conditions gate new runs, not replies that +continue an existing thread. If a reply races the very first trigger before its run exists, it falls +back to an ephemeral "a run is already active" notice (reason `concurrent_run_active`); a reply +after 7 idle days starts a fresh run. These automation follow-ups also forward text only. Attachments on a thread reply are not delivered to the session. @@ -321,7 +376,26 @@ to the session. ## Schedule Options -The schedule picker offers four presets and a custom mode: +The **Schedule** field chooses **Recurring** or **Run once**. The mode is fixed after creation. + +### Run Once + +**Run once** takes a **Run at** date and time, entered in the browser's local timezone and stored as +`scheduleOnceAt` (epoch milliseconds). It must be in the future. The automation starts on the first +scheduler tick at or after that time, consuming its slot when the firing is admitted. It stays +enabled while its runs are active or waiting to retry, then disables itself. A one-shot whose slot +comes due while a previous run is active is recorded as **Skipped** and still consumes the slot. + +To cancel a pending one-shot, including any waiting retries, pause or delete it. Editing the time +reschedules it and withdraws retries waiting on the previous time. A paused one-shot whose time has +passed must be given a new future time before it can be resumed. Fired one-shots that completed, or +were skipped because a previous run was active, are hidden from the automations list once no run is +active; their history stays readable on the detail page. A one-shot skipped because execution +authorization was denied stays listed so its access or executor can be repaired. + +### Recurring Schedules + +The recurring schedule picker offers four presets and a custom mode: | Preset | Description | Controls | | -------------- | -------------------------------------------- | ------------------------------------- | @@ -355,19 +429,70 @@ Examples: > **Note**: Six-field expressions (with seconds) are not supported. +### Launch Retries + +**Retries** (`maxRetries`, none by default, up to 5) relaunches a scheduled run that failed before +it tried to enqueue its first prompt, for example when a D1 stall stops the session from being +created. It applies to recurring and one-shot schedules alike. + +- **Not retried**: **Trigger Now** runs; runs that fail once the first-prompt enqueue has been + attempted, even if that call fails (the session may have accepted it, so a retry could repeat the + work), including agent failures, timeouts, and budget stops; and configuration errors such as a + model the harness cannot run. +- **Backoff**: the first retry waits 2 minutes and the wait doubles for each later attempt. While it + waits, the run shows **Retrying** with the launch error and the time of the next attempt. +- **Attempts**: each attempt opens a new session under the same run row, which shows the attempt + number. Execution authorization is checked again before every attempt. +- **Auto-pause**: a run counts as a success only if its relaunched session completes successfully; + one that exhausts its retries counts as a single failure. A waiting run counts as active for + concurrency. +- **Cancellation**: pausing or deleting the automation, or lowering **Retries** below the attempts a + waiting run has made, fails that run within a minute. + --- ## Managing Automations +Workspace automations are readable with `automations.read`; team automations additionally require +owning-team membership or a workspace Owner/Administrator role. Executors and owning-team leads can +manage and trigger eligible automations with the corresponding `own` permissions; `any` permissions +allow those actions across eligible automations. Built-in Owners and Administrators have the latter; +Viewers cannot manage or trigger. These resource checks apply even in `off` or `shadow` session +enforcement mode. + +Scheduled and event runs use the executor's authority. Each run checks that the execution user is +active, can create sessions and use its targets, and, for team automations, is a current member of +the active owning team. Current team grants must cover all repositories being launched. Generated +sessions inherit the automation's owning team and that team's default visibility; workspace runs +remain workspace-owned and workspace-visible. Environment targets must have the same ownership as +the automation; unlike the team session picker, a team automation cannot select a workspace-owned +environment. + +If a scheduled run is denied execution authorization, it is recorded as **Skipped** and the +automation is paused immediately, clearing its next run time without adding a failure strike. +Restore the required access or reassign the executor, then click **Resume**. Restoring access or +reassigning alone does not resume the schedule. Event-driven authorization denials skip the event +without pausing the automation. + +### Executor Reassignment + +A team lead or workspace Owner/Administrator with automation management access can use +`PATCH /automations/:id` with `{"userId":""}` to change the executor. Being the +executor alone does not permit reassignment. The replacement must be active, able to launch the +stored targets, and a member of the active owning team for a team automation. The change is audited +as `automation.executor_changed`. Reassignment allows recovery when the old executor loses access +without recreating the definition; it does not move ownership or rewrite existing sessions. + ### Pause and Resume **Pausing** an automation stops it from firing. Scheduled automations will not run on their cron, -and event-driven automations will ignore incoming events until resumed. You can pause from the -automations list or the detail page. +one-shots will not start (waiting retries fail), and event-driven automations will ignore incoming +events until resumed. You can pause from the automations list or the detail page. -**Resuming** reactivates the automation. Scheduled automations calculate the next run time from the -current moment. Event-driven automations resume listening immediately. Resuming also resets the -consecutive failure counter (see [Auto-Pause](#auto-pause) below). +**Resuming** reactivates the automation. Recurring automations calculate the next run time from the +current moment; one-shots keep their configured future time. Event-driven automations resume +listening immediately. Resuming also resets the consecutive failure counter (see +[Auto-Pause](#auto-pause) below). ### Trigger Now @@ -377,16 +502,34 @@ triggers follow the same concurrency rules as all other runs: if a run is alread trigger is rejected. Trigger Now also works while the automation is paused, so you can verify a fix before resuming. +Manual runs use the requester's authority and linked source-control credentials, not the stored +executor's. In addition to trigger permission, the requester must pass runtime session/target checks +and be a current member of the active owning team for a team automation, even if they are a +workspace Owner or Administrator. + ### Edit You can change an automation's name, repository selection, branch, model, and instructions at any time — including adding or removing repositories while a run is active (in-flight sessions are unaffected; the next firing uses the new selection). For scheduled automations, you can also change -the schedule and timezone. Repository-scoped triggers require exactly one repository; other trigger -types can be changed to **No repository**. +the schedule, timezone, and retries; a one-shot's time can change, but not its schedule mode. +Repository-scoped triggers require exactly one repository; other trigger types can be changed to +**No repository**. If you update the schedule or timezone, the next run time is recalculated automatically. +### Duplicate + +**Duplicate** on the detail page opens the create form (`/automations/new?from=`) filled in from +the automation. The copy is named ` (copy)`, trimmed to the name limit, and is saved only when +you click **Create Automation**, as a new automation with you as its executor and no run history. +Nothing that authenticates a trigger is copied: an inbound webhook copy gets its own URL and a new +API key, and a Sentry copy asks for the Sentry client secret again and gets its own webhook URL, +which the Sentry Custom Integration must point at. If you cannot create automations in the source's +team, the copy starts in your current scope with no repositories or environments; targets you cannot +use are left out. A notice above the form says when either happens. The button appears only when you +can create automations. + ### Delete Deleting an automation stops all future runs and removes it from the list. Existing run history and @@ -396,8 +539,17 @@ any sessions it created are preserved. ## Run History -Each automation's detail page shows a chronological list of runs — one row per firing — with status, -duration, and links to the underlying sessions. +Each automation's detail page shows a chronological list of runs — one row per recorded invocation — +with status, duration, and links to the underlying sessions. Generic event execution-authorization +denials do not create invocation records and do not appear in this history. + +GitHub repository-grant denials are an exception. Matching events without an owning-team grant for +the repository can record a sessionless **Unauthorized** invocation with reason `repo_not_granted` +before executor authorization, including when the executor cannot be resolved. A missing executor +alone does not create that record. These denials increment the event response's `skipped` count, do +not pause the automation, and neither add a failure strike nor reset existing failures. See +`packages/control-plane/src/automation/github-event-admission.ts` and +`packages/control-plane/src/db/github-automation-store.ts` for this pre-admission path. A single-repository firing renders as a flat row, exactly as before. A multi-repository firing renders as one expandable row summarizing its repositories (for example "10 repositories — 8 @@ -406,16 +558,21 @@ reason, and session link. ### Run Statuses -| Status | Meaning | -| ------------------- | ------------------------------------------------------------------------------------------------------ | -| **Starting** | A session is being created for this run. | -| **Running** | At least one session is actively executing. | -| **Completed** | Every session finished successfully. | -| **Failed** | Every session encountered an error. The failure reason is shown on the run. | -| **Partial failure** | A multi-repository run where some repositories completed and some failed. | -| **Skipped** | The run was skipped because a previous run was still active (see [Concurrent Runs](#concurrent-runs)). | - -Click **View session** on any run to jump to the full session with its output and artifacts. +| Status | Meaning | +| ------------------- | ------------------------------------------------------------------------------------------------------------------- | +| **Starting** | A session is being created for this run. | +| **Running** | At least one session is actively executing. | +| **Retrying** | A scheduled run failed to start and is waiting to launch again (see [Launch Retries](#launch-retries)). | +| **Completed** | Every session finished successfully. | +| **Failed** | Every session encountered an error. The failure reason is shown on the run. | +| **Partial failure** | A multi-repository run where some repositories completed and some failed. | +| **Skipped** | A previous run was still active, or a scheduled firing was denied execution authorization. | +| **Unauthorized** | GitHub event admission found no owning-team grant for the repository (`repo_not_granted`); no session was launched. | + +When authorized, click **View session** to open the session with its output and artifacts. +Automation read access is not session read access: history redacts session IDs, titles, and artifact +summaries for sessions the viewer cannot read. Lists do not expose private sessions solely through +an Owner's break-glass privilege; a qualifying single-run read audits that access. --- @@ -423,11 +580,11 @@ Click **View session** on any run to jump to the full session with its output an Automations display one of three statuses: -| Status | Meaning | -| ------------ | ------------------------------------------------------------------------------------- | -| **Enabled** | Running normally and ready to respond to its trigger. | -| **Degraded** | Enabled but has recent consecutive failures. The failure count is shown on the badge. | -| **Paused** | Not firing. Either manually paused or auto-paused after repeated failures. | +| Status | Meaning | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| **Enabled** | Running normally and ready to respond to its trigger. | +| **Degraded** | Enabled but has recent consecutive failures. The failure count is shown on the badge. | +| **Paused** | Not firing. Manually paused, auto-paused after repeated failures, or immediately paused after scheduled execution authorization denial. | --- @@ -439,12 +596,13 @@ those triggers fires while a previous run is still in progress, the new run is r Event-driven automations use concurrency keys instead. For inbound webhooks, retries with the same `idempotencyKey` are treated as the same event, but separate deliveries without a shared -`idempotencyKey` can overlap. +`idempotencyKey` can overlap. Deliveries that share a `sessionKey` are keyed by it: a later one +continues the first run's session rather than overlapping it. -Slack Message triggers key concurrency by thread. Replies in a thread are not skipped — for 7 days -after the thread's first trigger they continue the same session (during the run and after it -finishes), routed to that session as follow-up prompts (see the **Run feedback** note under -[Slack Message Triggers](#slack-message-triggers)). +Slack Message triggers key concurrency by thread. Replies in a thread are not skipped — until the +session has been idle for 7 days they continue the same session (while the run starts, during it, +and after it finishes), routed to that session as follow-up prompts (see the **Run feedback** note +under [Slack Message Triggers](#slack-message-triggers)). This prevents overlapping sessions from interfering with each other on the same repository. @@ -481,4 +639,5 @@ auto-pause threshold. | Webhook payload size | 64 KB | | Concurrent runs per automation | 1 for scheduled/manual triggers only | | Consecutive failures before auto-pause | 3 | +| Launch retries per scheduled run | 5 | | Run execution timeout | 90 minutes | diff --git a/docs/AVAILABLE_MODELS.md b/docs/AVAILABLE_MODELS.md index 141ebe5e15..82acd16c1a 100644 --- a/docs/AVAILABLE_MODELS.md +++ b/docs/AVAILABLE_MODELS.md @@ -1,16 +1,18 @@ # Available Models Open-Inspect exposes these models in the model picker and integration preferences. The default -enabled set includes Anthropic and OpenAI models. xAI / SuperGrok, OpenCode Zen, Z.AI Coding Plan, -and DeepSeek models are available but must be enabled in **Settings > Models**. OpenAI and SuperGrok -subscriptions are configured in **Settings > Provider Accounts**; Z.AI Coding Plan requires -`ZHIPU_API_KEY`; DeepSeek requires `DEEPSEEK_API_KEY`. +enabled set includes Anthropic and OpenAI models. xAI / SuperGrok, OpenCode Zen, OpenCode Go, Z.AI +Coding Plan, and DeepSeek models are available but must be enabled in **Settings > Models**. OpenAI +and SuperGrok subscriptions are configured in **Settings > Provider Accounts**; OpenCode Zen and +OpenCode Go require `OPENCODE_API_KEY`; Z.AI Coding Plan requires `ZHIPU_API_KEY`; DeepSeek requires +`DEEPSEEK_API_KEY`. OpenAI, xAI and Anthropic session selectors offer provider policy, any active connected account, and API-key mode. Automation editors can resolve defaults on each run or pin an account/API-key choice. Unattended Slack, GitHub, Linear, and unpinned automation launches follow the provider's configured -unattended mode. For Anthropic that policy reaches only Claude Agent automations: Slack, GitHub and -Linear launches run on OpenCode, which uses the API key. +unattended mode. For Anthropic that policy reaches only Claude Agent sessions. GitHub and Linear +launches run on the harness chosen in their integration settings, and Slack launches on the user's +App Home harness or the Slack setting, OpenCode by default. An OpenCode launch uses the API key. ## Harnesses @@ -34,12 +36,14 @@ Accounts) applies only on the Claude Agent harness; OpenCode sessions use `ANTHR | `anthropic/claude-haiku-4-5` | Claude Haiku 4.5 | Fast and efficient | high, max | max | | `anthropic/claude-sonnet-4-5` | Claude Sonnet 4.5 | Balanced performance | high, max | max | | `anthropic/claude-sonnet-4-6` | Claude Sonnet 4.6 | Balanced, fast coding | low, medium, high, max | high | -| `anthropic/claude-sonnet-5` | Claude Sonnet 5 | Latest Sonnet, adaptive thinking | low, medium, high, xhigh, max | high | +| `anthropic/claude-sonnet-5` | Claude Sonnet 5 | Balanced performance, adaptive thinking | low, medium, high, xhigh, max | high | +| `anthropic/claude-sonnet-5-5` | Claude Sonnet 5.5 | Latest Sonnet, fast and intelligent | low, medium, high, xhigh, max | high | | `anthropic/claude-opus-4-5` | Claude Opus 4.5 | Most capable | high, max | max | | `anthropic/claude-opus-4-6` | Claude Opus 4.6 | Most capable, adaptive thinking | low, medium, high, max | high | | `anthropic/claude-opus-4-7` | Claude Opus 4.7 | Most capable, adaptive thinking | low, medium, high, xhigh, max | high | | `anthropic/claude-opus-4-8` | Claude Opus 4.8 | Most capable, adaptive thinking | low, medium, high, xhigh, max | high | -| `anthropic/claude-opus-5` | Claude Opus 5 | Latest Opus, adaptive thinking | low, medium, high, xhigh, max | high | +| `anthropic/claude-opus-5` | Claude Opus 5 | Most capable, adaptive thinking | low, medium, high, xhigh, max | high | +| `anthropic/claude-opus-5-5` | Claude Opus 5.5 | Latest Opus, long-running agentic coding | low, medium, high, xhigh, max | high | | `anthropic/claude-fable-5` | Claude Fable 5 | Most powerful, new tier above Opus | low, medium, high, xhigh, max | high | | `anthropic/claude-fable-5-1` | Claude Fable 5.1 | Demanding reasoning and long-horizon agentic work | low, medium, high, xhigh, max | high | @@ -48,16 +52,17 @@ Accounts) applies only on the Claude Agent harness; OpenCode sessions use `ANTHR OpenAI models support connected ChatGPT provider accounts or `OPENAI_API_KEY` mode. See [Using OpenAI Models](OPENAI_MODELS.md) for account setup and coexistence details. -| Model ID | Display name | Description | Reasoning efforts | Default effort | -| ---------------------------- | ------------------- | ---------------------------------------------- | ------------------------------ | -------------- | -| `openai/gpt-5.4` | GPT 5.4 | Flagship model | none, low, medium, high, xhigh | Not set | -| `openai/gpt-5.5` | GPT 5.5 | Latest flagship model | none, low, medium, high, xhigh | Not set | -| `openai/gpt-5.6-sol` | GPT 5.6 Sol | Frontier model for complex professional work | none, low, medium, high, xhigh | Not set | -| `openai/gpt-5.6-terra` | GPT 5.6 Terra | Balanced, cost-efficient everyday work | none, low, medium, high, xhigh | Not set | -| `openai/gpt-5.6-luna` | GPT 5.6 Luna | Fast, cost-efficient high-volume workloads | none, low, medium, high, xhigh | Not set | -| `openai/gpt-6-astra` | GPT-6 Astra | Most capable model for complex, demanding work | low, medium, high, xhigh, max | medium | -| `openai/gpt-5.3-codex` | GPT 5.3 Codex | Latest codex | low, medium, high, xhigh | high | -| `openai/gpt-5.3-codex-spark` | GPT 5.3 Codex Spark | Low-latency codex variant | low, medium, high, xhigh | high | +| Model ID | Display name | Description | Reasoning efforts | Default effort | +| ---------------------- | ------------- | --------------------------------------------------- | ----------------------------------- | -------------- | +| `openai/gpt-5.4` | GPT 5.4 | Flagship model | none, low, medium, high, xhigh | Not set | +| `openai/gpt-5.5` | GPT 5.5 | Latest flagship model | none, low, medium, high, xhigh | Not set | +| `openai/gpt-5.6-sol` | GPT 5.6 Sol | Frontier model for complex professional work | none, low, medium, high, xhigh | medium | +| `openai/gpt-5.6-terra` | GPT 5.6 Terra | Balanced, cost-efficient everyday work | none, low, medium, high, xhigh | medium | +| `openai/gpt-5.6-luna` | GPT 5.6 Luna | Fast, cost-efficient high-volume workloads | none, low, medium, high, xhigh, max | medium | +| `openai/gpt-6-astra` | GPT-6 Astra | Most capable model for complex, demanding work | low, medium, high, xhigh, max | medium | +| `openai/gpt-6-sol` | GPT-6 Sol | Complex coding and agentic workflows | none, low, medium, high, xhigh, max | medium | +| `openai/gpt-6.1-sol` | GPT-6.1 Sol | Complex coding, computer use, and professional work | low, medium, high, xhigh, max | medium | +| `openai/gpt-6-luna` | GPT-6 Luna | Efficient model for focused, high-volume tasks | none, low, medium, high, xhigh, max | medium | ## xAI / SuperGrok @@ -65,14 +70,18 @@ Grok models support connected SuperGrok provider accounts or `XAI_API_KEY` mode default. See [Using Grok with a SuperGrok Subscription](GROK_MODELS.md) for setup and rollout instructions. -| Model ID | Display name | Description | Reasoning efforts | Default effort | -| -------------------- | -------------- | ----------------------------------------------- | ----------------- | -------------- | -| `xai/grok-4.5` | Grok 4.5 | Grok for chat, coding, and agentic tools | low, medium, high | high | -| `xai/grok-4.6` | Grok 4.6 | Latest Grok for chat, coding, and agentic tools | low, medium, high | high | -| `xai/grok-build-0.1` | Grok Build 0.1 | Coding model for SuperGrok subscribers | Not configurable | N/A | +| Model ID | Display name | Description | Reasoning efforts | Default effort | +| -------------------- | -------------- | ----------------------------------------------- | ------------------------ | -------------- | +| `xai/grok-4.5` | Grok 4.5 | Grok for chat, coding, and agentic tools | low, medium, high | high | +| `xai/grok-4.6` | Grok 4.6 | Grok for chat, coding, and agentic tools | low, medium, high, xhigh | high | +| `xai/grok-4.7` | Grok 4.7 | Latest Grok for chat, coding, and agentic tools | low, medium, high, xhigh | high | +| `xai/grok-build-0.1` | Grok Build 0.1 | Coding model for SuperGrok subscribers | Not configurable | N/A | ## OpenCode Zen +OpenCode Zen models require `OPENCODE_API_KEY` as a global or repository secret. Zen is +pay-per-token against `https://opencode.ai/zen/v1`. + | Model ID | Display name | Description | Reasoning efforts | Default effort | | ----------------------- | ------------ | ------------- | ----------------- | -------------- | | `opencode/kimi-k2.5` | Kimi K2.5 | Moonshot AI | Not supported | N/A | @@ -84,6 +93,64 @@ instructions. | `opencode/glm-5.1` | GLM 5.1 | Z.ai | Not supported | N/A | | `opencode/glm-5.2` | GLM 5.2 | Z.ai | Not supported | N/A | +## OpenCode Go + +[OpenCode Go](https://opencode.ai/docs/go/) is a flat-rate subscription over the same credential as +Zen: one `OPENCODE_API_KEY` global or repository secret serves both. Go routes to a separate gateway +(`https://opencode.ai/zen/go/v1`) and bills against the subscription's rolling usage allowance +instead of per token, so a key without an active Go subscription fails on `opencode-go/*` models +while still working on `opencode/*` ones. + +Usage is capped on three rolling windows — 20% of the monthly allowance per 5 hours, 50% per week, +100% per month. Allowances differ per model, so an unattended session pinned to a Go model can +exhaust its window and fail mid-run; keep Go models off unattended Slack, GitHub, Linear, and +automation launches unless you accept that. + +The catalog mirrors the model list Go publishes under +[Endpoints](https://opencode.ai/docs/go/#endpoints), in the same order, with one exception: Go +documents `minimax-m2.5`, but the OpenCode release pinned in +`packages/sandbox-images/toolchain.json` does not resolve `opencode-go/minimax-m2.5` +(`opencode models` lists 27 Go models, not 28), so it is left out rather than offered as a selection +that fails. That model is reachable as `opencode/minimax-m2.5` on Zen. + +| Model ID | Display name | Description | Reasoning efforts | Default effort | +| ------------------------------------------ | ---------------------------- | ----------------------------- | ----------------- | -------------- | +| `opencode-go/grok-4.6` | Grok 4.6 | xAI | Not supported | N/A | +| `opencode-go/gpt-5.6-luna` | GPT 5.6 Luna | OpenAI | Not supported | N/A | +| `opencode-go/glm-5.3-flash` | GLM 5.3 Flash | Z.ai | Not supported | N/A | +| `opencode-go/glm-5.3` | GLM 5.3 | Z.ai | Not supported | N/A | +| `opencode-go/glm-5.2` | GLM 5.2 | Z.ai | Not supported | N/A | +| `opencode-go/glm-5.1` | GLM 5.1 | Z.ai | Not supported | N/A | +| `opencode-go/kimi-k3` | Kimi K3 | Moonshot AI | Not supported | N/A | +| `opencode-go/kimi-k2.7-code` | Kimi K2.7 Code | Moonshot AI | Not supported | N/A | +| `opencode-go/kimi-k2.6` | Kimi K2.6 | Moonshot AI | Not supported | N/A | +| `opencode-go/longcat-2.0` | LongCat 2.0 | Meituan | Not supported | N/A | +| `opencode-go/deepseek-v4.1-flash` | DeepSeek V4.1 Flash | DeepSeek | Not supported | N/A | +| `opencode-go/deepseek-v4-pro` | DeepSeek V4 Pro | DeepSeek | Not supported | N/A | +| `opencode-go/deepseek-v4-flash` | DeepSeek V4 Flash | DeepSeek | Not supported | N/A | +| `opencode-go/deepseek-v4-flash-vision-exp` | DeepSeek V4 Flash Vision Exp | DeepSeek, experimental vision | Not supported | N/A | +| `opencode-go/mimo-v2.5` | MiMo V2.5 | Xiaomi | Not supported | N/A | +| `opencode-go/mimo-v2.5-pro` | MiMo V2.5 Pro | Xiaomi | Not supported | N/A | +| `opencode-go/minimax-m3` | MiniMax M3 | MiniMax | Not supported | N/A | +| `opencode-go/minimax-m2.7` | MiniMax M2.7 | MiniMax | Not supported | N/A | +| `opencode-go/muse-spark-1.3-contributor` | Muse Spark 1.3 Contributor | Multimodal contributor tier | Not supported | N/A | +| `opencode-go/muse-spark-1.2-contributor` | Muse Spark 1.2 Contributor | Multimodal contributor tier | Not supported | N/A | +| `opencode-go/qwen3.8-max` | Qwen3.8 Max | Alibaba Cloud | Not supported | N/A | +| `opencode-go/qwen3.8-flash` | Qwen3.8 Flash | Alibaba Cloud | Not supported | N/A | +| `opencode-go/qwen3.7-max` | Qwen3.7 Max | Alibaba Cloud | Not supported | N/A | +| `opencode-go/qwen3.7-plus` | Qwen3.7 Plus | Alibaba Cloud | Not supported | N/A | +| `opencode-go/qwen3.6-plus` | Qwen3.6 Plus | Alibaba Cloud | Not supported | N/A | +| `opencode-go/hy4-preview` | Hy4 Preview | Tencent Hunyuan | Not supported | N/A | +| `opencode-go/hy3` | Hy3 | Tencent Hunyuan | Not supported | N/A | + +Some of these models are reachable through more than one provider, billed against different +credentials. `opencode-go/grok-4.6` and `xai/grok-4.6` are the same Grok model behind different +gateways; `opencode-go/glm-5.2`, `opencode/glm-5.2` and `zai-coding-plan/glm-5.2` are the same GLM +model behind three. Pick the entry whose billing you want. + +These models run on the OpenCode harness only — the Claude Agent harness runs Anthropic models +exclusively. + ## Z.AI Coding Plan Z.AI Coding Plan models require `ZHIPU_API_KEY` as a global or repository secret. diff --git a/docs/AWS_BRING_UP.md b/docs/AWS_BRING_UP.md index 6b9ab7a568..68ee1247c4 100644 --- a/docs/AWS_BRING_UP.md +++ b/docs/AWS_BRING_UP.md @@ -206,9 +206,135 @@ docker buildx build --platform linux/arm64 \ -t "$REGISTRY:latest" --push . ``` -Once I-3 lands this is CI's job, and the deploy is a tag push plus step 6. +That is the bootstrap: the module points a first boot at `:latest`, so one image has to exist before +the stack can start. Every deploy after this is CI's, and the section below sets that up. + +## 6. Deploys from CI + +`.github/workflows/deploy-aws.yml` builds the image, pushes it, moves the deployed version and +verifies the result — rolling back on its own if the new image does not come up. It authenticates +with an OIDC token rather than an access key, so no AWS credential is stored in the repository. + +Two things make that safe rather than merely convenient. The role's trust policy pins both the +repository and the **GitHub environment** the job asked for, so the environment's own rules are an +AWS access gate and not a UI convention. And the role can reach exactly three things: push to the +one ECR repository, read and write the one SSM parameter naming the deployed image, and run a +command on the one instance. It cannot read the secrets sitting next to that parameter. + +The first of those is only as strong as the environment you configure, and the failure is quiet. The +subject names the repository and the environment; it does not name a branch. GitHub also **creates a +referenced environment on the fly, with no protection rules, if it does not exist** — so a workflow +dispatched from any branch would present the same subject and assume the same role. The +environment's own **deployment branch rule** is what makes the pin mean anything, which is why the +next section treats it as a step rather than an option. + +Turn it on by setting `github_deploy` in `terraform.tfvars`: + +```hcl +github_deploy = { + repository = "your-org/your-repo" + environment = "aws-staging" # the GitHub environment the deploy job requests +} +``` + +The OIDC provider is one per AWS account. Whichever environment you apply first creates it; give the +other the first's ARN, or the second apply fails on a provider that already exists: + +```hcl +github_deploy = { + repository = "your-org/your-repo" + environment = "aws-production" + oidc_provider_arn = "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" +} +``` + +Then create that GitHub environment under Settings → Environments — **before** the first apply, so +it exists with rules rather than being created without any by the first run that names it — and set +both of these on it: + +- **Deployment branches**: selected branches, `main` only. Without this the trust policy pins an + environment that any branch can ask for. +- **Required reviewers**: at least one for production. This is the approval gate the trust policy + turns into an AWS access gate. + +Then give it these **variables** — every one of them a `terraform output`: + +| Variable | From | +| ------------------------------ | ------------------------------------------------ | +| `AWS_DEPLOY_ROLE_ARN` | `terraform output -raw github_deploy_role_arn` | +| `AWS_REGION` | `terraform output -raw region` | +| `AWS_ECR_REPOSITORY` | `terraform output -raw ecr_repository_url` | +| `AWS_INSTANCE_ID` | `terraform output -raw instance_id` | +| `AWS_DEPLOYED_IMAGE_PARAMETER` | `terraform output -raw deployed_image_parameter` | +| `AWS_PUBLIC_URL` | `https://` + `terraform output -raw hostname` | + +None is a secret; they are all names and identifiers, which is why they are variables rather than +secrets. To deploy on every push to `main`, set the **repository** variable +`AWS_DEPLOY_ON_PUSH_ENVIRONMENT` to the environment name. Leave it unset and the workflow skips +every job and only does anything when dispatched by hand, which is the right default for production +— and means an installation that never deploys to AWS pays nothing for this workflow existing. + +### What a deploy does + +1. Pins the current deployed-image parameter to its immutable digest **before pushing anything**. + This preserves the bootstrap image even when its `:latest` tag is overwritten by the first CI + deployment. An already pinned value is unchanged. A tag outside the configured ECR repository + must first be replaced in SSM with the digest of the running image; CI fails before pushing if it + cannot resolve a rollback digest. +2. Builds `packages/control-plane/Dockerfile` on a native arm64 runner, pushes it as + `:` plus `:latest` for a future first boot, and then resolves what it pushed to + a digest. **The digest is what gets deployed.** Both tags move — re-running the workflow on a + commit that is already deployed would repoint `:` at the new build, and a rollback restoring + that string would put back the image it was rolling back from. +3. Reads the current value of the deployed-image parameter — before activating a new image, because + that is what a rollback restores. +4. Writes the new reference and, over SSM, fetches and activates on the instance: the fetch brings + down the stack files, `.env` and the activation script itself, and the activation pulls and + `up -d --wait`s **without stopping the old stack first**. A failure before the swap leaves the + running deployment untouched. The command names those two steps rather than anything cloud-init + installed, because the instance ignores `user_data_base64` — a host keeps whatever was written at + its first boot, so a deploy that assumed otherwise would depend on how old the instance is. +5. Polls `/healthz` until it answers six times in a row. One 200 proves the port is open, not that + the deployment works. +6. On a confirmed activation failure or failed health check: puts the previous digest back, + activates it, and fails the job anyway. A working rollback is not a successful deploy. + +The command polling budget includes delivery, execution, and a reporting grace period. If no +terminal response arrives, the delivery times out without an agent-confirmed result, or the +SendCommand response is lost, the job fails **without changing the parameter again or starting a +rollback**. A local timeout is not proof that a remote activation has stopped. The job logs the +previous digest and, when available, the command ID. Confirm the remote command has stopped and +inspect the running stack before restoring that digest and activating it manually; do not rerun the +workflow while the previous activation's outcome remains unknown. + +For manual image pushes, run the same preparation from the Terraform environment directory before +overwriting any tags: + +```bash +AWS_REGION="$(terraform output -raw region)" \ +AWS_ECR_REPOSITORY="$(terraform output -raw ecr_repository_url)" \ +DEPLOYED_IMAGE_PARAMETER="$(terraform output -raw deployed_image_parameter)" \ + ../../../scripts/deploy-aws.sh --pin-current-image +``` + +`scripts/deploy-aws.sh` owns that sequence and is covered by `npm run test:deploy-aws`, which +exercises the rollback against a stubbed AWS CLI — the path that only ever runs when something has +already gone wrong. -## 6. Start it +### Terraform stops owning the version + +`CONTROL_PLANE_IMAGE` is created by Terraform and then left alone (`ignore_changes`), the same +boundary the secrets use. So after the first deploy, `control_plane_image_tag` no longer describes +what is running; `terraform output deployed_image_parameter` names where that lives, and + +```bash +aws ssm get-parameter --name "$(terraform output -raw deployed_image_parameter)" \ + --query 'Parameter.Value' --output text +``` + +answers what is deployed right now. + +## 7. Start it Every restart re-fetches the compose files from S3, rebuilds `.env` from SSM and re-pulls the image, so this is also how a deploy and a configuration change take effect. It is never a new instance. @@ -222,6 +348,17 @@ sudo systemctl restart open-inspect sudo systemctl status open-inspect ``` +`restart` stops the stack before it fetches, so a failure in S3, SSM or ECR leaves nothing running +until the unit's retry comes round. Once something is already serving, prefer the two steps a deploy +uses, which fetch and pull first and swap only on success: + +```bash +sudo /usr/local/bin/open-inspect-deploy +# which is the two steps a deploy runs, and works on any instance: +sudo /usr/local/bin/open-inspect-fetch-config +sudo bash /opt/open-inspect/deploy.sh +``` + Then, from anywhere: ```bash @@ -329,8 +466,10 @@ variation rather than a requirement. ## Not here yet -- **CI apply.** These environments are applied from a laptop today. I-3 moves them into the - pipeline. +- **`terraform apply` from CI.** Deploys are automated (section 6), but infrastructure changes are + not: the workflow moves the image and restarts the stack, and never runs Terraform. Applying these + environments is still a laptop job, and a change to the compose files or to `config` needs that + apply before a deploy will pick it up. - **Alarms beyond the instance's status checks.** Disk and memory need the CloudWatch agent, and the alarms that describe the control plane itself need metrics the host does not publish yet; both are H-8. diff --git a/docs/BOAT_SANDBOX_PROVIDER.md b/docs/BOAT_SANDBOX_PROVIDER.md new file mode 100644 index 0000000000..b3a5a3c613 --- /dev/null +++ b/docs/BOAT_SANDBOX_PROVIDER.md @@ -0,0 +1,377 @@ +# Boat Sandbox Provider + +Open-Inspect can use [Boat](https://boat.dev) as the sandbox provider for coding sessions. The +control plane talks directly to the Boat REST API from Cloudflare Workers — there is no separate +service to deploy for this provider; only the base snapshot is built (by Terraform, or manually). + +## When to Use It + +Use `sandbox_provider = "boat"` when you want sandbox sessions to run in Boat's full Ubuntu virtual +machines (real `systemd`, Docker, passwordless `sudo`) while keeping the same Open-Inspect control +plane, web app, GitHub OAuth, and Slack/GitHub integrations. Boat sandboxes support stop/resume: an +idle session's disk is snapshotted and its machine released, and the next prompt brings the same +sandbox back on a new machine with its files intact. + +## Required Configuration + +Set these values in `terraform/environments/production/terraform.tfvars`: + +```hcl +sandbox_provider = "boat" + +boat_api_key = "boat_..." # from the Boat dashboard → API keys +boat_base_snapshot = "open-inspect" # prefix of the saved snapshot to build/use + +# Optional +# boat_api_url = "https://boat.dev/api/v1" # REST API base URL +# boat_org = "" # organization wallet; empty = active wallet +# boat_sandbox_type = "default" # machine size when a session requests no resources +``` + +For GitHub Actions-based deployment, configure the matching repository secrets and variables: + +```text +SANDBOX_PROVIDER=boat +BOAT_API_KEY +BOAT_BASE_SNAPSHOT +BOAT_API_URL # optional +BOAT_ORG # optional +BOAT_SANDBOX_TYPE # optional +``` + +The Boat provider also needs the normal Open-Inspect values such as Cloudflare, GitHub App, and web +app configuration. See [GETTING_STARTED.md](./GETTING_STARTED.md) for the full deployment flow. + +`boat_base_snapshot` is the Terraform name prefix: snapshots use `-<16 lowercase hex>` from +the build-input hash. Boat names allow 63 lowercase letters, digits and dashes in total, so keep the +prefix to 46 characters or fewer. The base prefixes `oi-img` and every prefix starting with +`oi-img-` are reserved for prebuilt images and rejected by Terraform, the module, and build/prune +validation, including manual builds and prune candidates. + +Use a unique prefix for each deployment sharing a Boat account or organization wallet. Pruning +treats all exact matching names as belonging to that deployment; reusing a prefix can delete another +deployment's live base snapshot. + +### What a stop keeps + +A Boat snapshot saves `/home`, new top-level directories and your changes under `/etc`, `/usr`, +`/opt`, `/var`, `/root` and `/srv`. It never saves `/tmp` or `~/.cache`. A resume usually lands on a +new machine; only one within about ten minutes of the stop can get the parked machine back, with +`/tmp` and the running runtime intact. The launcher therefore points the runtime's cross-boot state +(`OPENINSPECT_STATE_DIR`, the harness session id and the repository manifest with the session-start +commits) at `/home/user/.openinspect/state`, so the agent's conversation and the Changes baseline +survive a resume either way. Files the agent writes under `/tmp` do not survive a resume on a new +machine, and tool caches under `~/.cache` are rebuilt there. + +### Billing to an organization + +When sandboxes are billed to a Boat organization (`boat_org`, or an organization set as the +account's active wallet), every member of the organization can see them, and a `shared` sandbox can +be used by every member: commands, files, ports. A session sandbox's env holds the sandbox auth +token and the user's secrets, so every create asks Boat for a private sandbox: session sandboxes, +image builds (see [Prebuilt Images](#prebuilt-images)) and the snapshot build's own sandboxes alike. +Boat sets the visibility before the sandbox has a machine, so it is never shared, not even for a +moment. A sandbox that still reports itself `shared` is made private +(`POST /sandboxes/{id}/unshare`) before it is used, and one that cannot be fails the spawn and is +deleted. + +Personal accounts need nothing: their sandboxes are visible to the owner only. + +## Snapshot Build + +Boat sandboxes deploy from a **saved snapshot** that contains: + +- the Open-Inspect sandbox runtime (`packages/sandbox-runtime`, reachable as `/app`) +- OpenCode and the OpenCode plugin dependencies +- Python 3.12 and Node 24 runtimes under `/opt/openinspect` +- `code-server`, `ttyd`, `agent-browser`, and the browser/desktop tooling used by the agent runtime +- GitHub CLI and a Git credential helper +- the launcher (`/home/user/openinspect/launcher.sh`) that starts the runtime on every boot + +The snapshot is built by `packages/boat-infra/build-template.py` from the shared image bundle +([`packages/sandbox-images`](../packages/sandbox-images/README.md)): a build sandbox is created from +Boat's stock Ubuntu image with none of your account's secrets, the bundle is installed in it, its +disk is saved under the name, and a second sandbox deploys from that name to run the image +verification. There are two supported paths. + +### Terraform-Managed Snapshot + +This is the recommended path for a normal deployment. When `sandbox_provider = "boat"`, the +production `boat.tf` computes the build-input hash through the `sandbox-images` CLI, covering the +runtime, image package, Boat builder and Terraform module. It passes that hash to +`terraform/modules/boat-infra`, which builds a new snapshot under a new name on `terraform apply` +when those inputs change. The control-plane Worker switches to the new name only after the build has +verified it, then Terraform prunes superseded bases (see +[Base Snapshot Pruning](#base-snapshot-pruning)). + +```bash +cd terraform/environments/production +terraform init +terraform apply +``` + +### Manual Snapshot + +Use this path to build or test a snapshot before wiring it into Terraform: + +```bash +cd packages/boat-infra +uv sync --frozen +export BOAT_API_KEY=boat_… +export BOAT_BASE_SNAPSHOT=open-inspect +uv run --frozen python build-template.py +``` + +Optional: `BOAT_API_URL`, `BOAT_ORG`, and `BOAT_BUILD_SANDBOX_TYPE` (default `default`; the machine +the image is installed on, which does not change the saved disk). Manual builds use +`-<12 hex>-` names. The command prints `{"reference": ""}`; set +that name as `BOAT_BASE_SNAPSHOT` on the control plane. See +[`packages/boat-infra/README.md`](../packages/boat-infra/README.md) for details. + +A build uses two sandbox starts and keeps one saved snapshot. Boat includes a fixed number of saved +snapshots per account or organization wallet and bills each one beyond that; base snapshots and +prebuilt images share the allowance. + +### Base Snapshot Pruning + +After the control-plane Worker switches to the selected base, Terraform runs +`packages/boat-infra/prune-deployed-snapshots.py`. Changes to the selected snapshot name, Boat API +URL or organization trigger cleanup, even with an unchanged build hash; credentials alone do not. It +also runs on the first apply after the pruning resource is introduced, not on every unchanged apply. +If pruning fails, the Worker stays switched and the next apply retries cleanup. Switching away from +Boat does not delete base snapshots. + +The wrapper reads Cloudflare's confirmed deployments and version bindings, verifies the expected +active deployment and desired Boat snapshot, and follows actual deployed base transitions, skipping +same-base redeploys. For `A -> B -> A -> C`, it retains `C` and `A`, not a newer undeployed +snapshot. It never infers the rollback base from snapshot `createdAt` values. Missing or +inconsistent history, split deployments, or a failed active-deployment recheck abort before any +deletion. If available history has no distinct predecessor (a new deployment or truncated history), +cleanup succeeds but skips pruning rather than guessing. + +A provider, Boat API URL, organization or prefix change is a scope boundary: the old scope is +untouched and there is no rollback base in the new scope. When pruning proceeds, it retains at most +two applicable bases, not necessarily two on a first deployment or scope change. Only exact +`-<16 lowercase hex>` names are candidates. The deployed base and any nonempty replaced base +must both be `ready` names under that prefix; otherwise pruning fails without deleting anything. +Pruning skips `saving` snapshots and deletes the remaining `ready` or `failed` candidates. Manual +names (`-<12 hex>-`), prebuilt images, other names and snapshots under other or +longer prefixes are untouched. + +The wrapper needs Cloudflare Workers Scripts: Read access; the existing Workers Scripts: Edit +deployment token is sufficient. Terraform supplies the token, account, Worker name and deployment ID +automatically, so no additional manual setup is needed. + +Serialize deployments, direct Worker rollbacks and pruning. The wrapper rechecks the active +deployment after reading history, but cannot atomically lock Cloudflare and Boat. + +For a manual preview, run `uv run --frozen python build-template.py prune --dry-run` from +`packages/boat-infra` with explicit `BOAT_DEPLOYED_SNAPSHOT` and `BOAT_REPLACED_SNAPSHOT` values +from Worker deployment history and credentials for the same prefix and wallet. Set the replaced +value empty only when you know there was no previous base in that scope, never to work around +missing history. A live-wallet dry run is required before manual deletion; omit `--dry-run` only +after confirming the selection. See the +[manual pruning procedure](../packages/boat-infra/README.md#prune-base-snapshots) for the required +environment variables. + +At most one rollback base is retained. A Worker rollback across two or more base generations may +reference a deleted base; sessions without prebuilt images then fail to create until a deployment +rebuilds that name. Deleting a source base does not affect existing sandboxes or standalone prebuilt +repository and environment images. + +## Runtime Behavior + +The Boat provider creates fresh sandboxes from the saved snapshot, delivering env and starting the +runtime the same way Open-Inspect does on every other provider: + +1. the sandbox is created with `noEnv` (none of the Boat account's own secrets or logins) and the + per-sandbox env — `CONTROL_PLANE_URL`, `SESSION_CONFIG`, the sandbox auth token, user secrets — + which Boat applies to every process the sandbox starts. The same request names the sandbox, makes + it private, routes the enabled service ports (code-server, terminal, noVNC) and the configured + tunnel ports to `https://-.on.boat.dev` URLs, and carries a setup script that + Boat runs as the sandbox user the moment the machine is up; the script waits for a launch marker, + then execs the baked launcher +2. the control plane writes the tunnel URLs to `/workspace/.tunnels.env`, then the launch marker + (`/tmp/openinspect-launch`), so the runtime starts neither before the sandbox is known private + nor before its tunnel file exists. Boat holds a write to a sandbox that is still starting until + the sandbox is ready, so the tunnel file lands in the workspace, which snapshots keep, while the + marker, under `/tmp`, exists only for this machine's launch. A port the create could not route is + hosted again with its own call +3. the launcher links `/workspace` and `/app` into the home directory, waits for the restored + toolchain, takes a single-instance lock in `/tmp`, and execs the supervisor + (`python -m sandbox_runtime.entrypoint`) with its output in + `/home/user/.openinspect/supervisor.log`. The supervisor records a completed boot in + `/home/user/.openinspect/boot-complete`; a later launch that finds that file boots in restored + mode, one that does not boots fresh again. A resume starts the launcher again with a detached + command, since the setup script runs only at create +4. the supervisor clones or syncs the selected repositories, starts OpenCode and code-server, and + connects the Open-Inspect bridge back to the control plane; readiness is the bridge phoning home, + and the shared connecting timeout fails the session otherwise + +Machine size follows the session's sandbox settings: a `cpuCores`/`memoryMib` request selects the +smallest Boat size that satisfies it (`small` 2 vCPU/4 GB, `default` 4/8, `large` 8/16, `xlarge` +16/32; `xlarge` needs a Boat plan that offers it), and a session without a request uses +`boat_sandbox_type`. The sandbox lifetime (`sandboxTimeoutMs`, default 2 hours) becomes Boat's +auto-stop TTL, clamped to Boat's bounds of one second to thirty days. + +Prebuilt repository and environment images are supported; see [Prebuilt images](#prebuilt-images). +All three harnesses run on Boat. Boat re-links its own coding agents, including a `codex`, under +`/usr/local/bin` at boot, so the Codex harness launches the image's pinned install under +`/opt/openinspect/codex`. + +### Ports + +Boat's own services hold ports 22, 6080, 8090, 8911 and 47984–48010 on every machine. A sandbox +setting that puts a service or tunnel on one of them is rejected at spawn time, and because 6080 is +noVNC's shared default, Boat sandboxes default the VNC port to **6081** instead. + +### Access URLs + +The three service ports carry their own authentication (code-server password, terminal token, VNC +password) and the web app embeds the terminal in an iframe, where Boat's access cookie would be a +blocked third-party cookie, so their routes are created as public hosted ports. User tunnel ports +stay behind Boat's token: their URLs carry a `_token` query parameter that a browser exchanges for a +cookie on the first visit. Treat a tunnel URL like a password. + +## Prebuilt Images + +Enabling prebuilds for a repository (Settings > Images) or an environment (Settings > Environments) +works as on every other provider ([IMAGE_PREBUILD.md](./IMAGE_PREBUILD.md)). On Boat: + +- a build sandbox deploys from the base snapshot with only the boot markers in its env, on the + machine size the scope's settings select. Once it is private and bound to the build, the control + plane writes the build's env (repositories, clone token, scope secrets, callback contract) to a + file under `/tmp`, which the launcher reads once and removes, and starts the launcher in + image-build mode; the runtime clones the repositories, runs each `.openinspect/setup.sh` and + reports back to the control plane +- finalization archives the build sandbox and saves its disk as a **saved snapshot** called + `oi-img--`, then deletes the build sandbox. The image is a standalone copy: it + keeps deploying after its source is gone and never expires until the control plane deletes it +- a session with a ready image deploys from that name instead of the base snapshot, with its own env + and a fresh boot; the runtime syncs the checkout and skips `setup.sh` + +Every image counts toward the wallet's saved-snapshot allowance together with the deployed and +rollback base snapshots. Superseded images are deleted automatically, and Terraform prunes +superseded bases after switching the Worker (see [Base Snapshot Pruning](#base-snapshot-pruning)). +The [Boat prebuilds](./IMAGE_PREBUILD.md#boat-prebuilds) section covers the allowance, what a saved +snapshot keeps (`/tmp` and `~/.cache` are not saved), why the build's env never reaches the image, +and the runbook. + +## Lifecycle: Stop and Resume + +Open-Inspect drives the lifecycle through the shared lifecycle manager, treating a Boat stop as a +**resumable archive**: + +- Idle sessions are **stopped** after the shared inactivity timeout (default 10 minutes): Boat + snapshots the disk and releases the machine. The stop is confirmed only once Boat reports the + sandbox archived with a snapshot available. +- Before the TTL lapses, the control plane drains the session and stops the sandbox itself; a + sandbox that reaches its TTL is archived by Boat the same way. +- The next prompt **resumes** the archived sandbox on a new machine (or the same one within about + ten minutes of the stop) and starts the runtime again. On the same machine the earlier runtime is + still running, so the launcher finds its lock taken, exits at once, and that runtime carries on. + The lock lives in `/tmp` because the first boot takes it while Boat's lazy restore still serves + the home directory; once the disk has hydrated, a lock file there would be a different file that a + second launcher could take as well. A sandbox whose earlier boot completed resumes in restored + mode, so the checkout is kept and `setup.sh` does not run twice; one whose boot was interrupted + before the harness came up boots fresh again. The hosted URLs stay the same across a stop and + resume, and so do the code-server and VNC passwords: the control plane keeps the create-time + credentials of a retained sandbox, so rotating `BOAT_API_KEY` does not lock existing sessions out. +- Boat keeps a stopped sandbox's disk for **14 days**. After that, and when Boat has lost the + sandbox, the next prompt spawns a fresh sandbox instead. +- Only sandboxes that fail before becoming usable, or are being replaced, are **deleted**. + +A stopped sandbox is not billed for machine time and does not count against Boat's concurrent +sandbox limit. Each create and resume counts as one Boat start against your plan's start limits; a +start refused with `429 rate_limited` fails the spawn transiently and is retried by the control +plane's normal recovery. + +## Required Secrets + +Terraform passes these provider-level values to the control plane: + +- `BOAT_API_KEY` — used for the Boat REST API **and** the code-server/VNC password HMAC at create + time, and to authenticate the snapshot build +- `BOAT_BASE_SNAPSHOT` +- `BOAT_API_URL`, `BOAT_ORG`, `BOAT_SANDBOX_TYPE` (optional) + +`BOAT_API_KEY`, `BOAT_API_URL` and `BOAT_ORG` stay bound while `boat_api_key` is set, even after +`sandbox_provider` changes, so prebuilt images and build sandboxes recorded against Boat can still +be finalized and reclaimed; `BOAT_BASE_SNAPSHOT` and `BOAT_SANDBOX_TYPE` follow the active provider. + +Model credentials are not among them: Boat sandboxes take every LLM API key from Open-Inspect's +secrets settings, so add `ANTHROPIC_API_KEY` there for Claude models and the equivalent key for any +other provider you use. The runtime also receives repository credentials from Open-Inspect for Git +operations. See [SECRETS.md](./SECRETS.md). + +Secrets named like Boat's own variables (`ASCII_TOKEN`, `ASCII_API_URL`, `AGENT_ID`, `PRODUCT_MODE`, +`ENVIRONMENT_ID`, `BOAT_ID`, `SERVICE_PREVIEW_TOKEN`, `BOAT_CLI_TOKEN`) cannot be delivered to a +Boat sandbox and are dropped with a warning in the control-plane logs. Boat allows at most 100 +variables and 64 KB of per-sandbox env; a session that exceeds either fails to spawn with a message +naming the limit. + +## Verify + +After `terraform apply`, verify: + +1. The control plane is healthy: + + ```bash + curl https://open-inspect-control-plane-..workers.dev/health + ``` + +2. The Boat dashboard (or `GET /saved-snapshots`) lists the Open-Inspect snapshot as `ready`. + +3. Starting a session in the web app creates a Boat sandbox and reaches `Connected`. + +4. Inside the session, ask a simple repo question such as: + + ```text + tell me about this repository + ``` + +If a session starts but never produces agent output, check the control-plane Worker logs for runtime +startup, bridge connection, and OpenCode health events. Session sandboxes are created with Boat +monitoring off, so Boat keeps no process output for them; the in-sandbox forensics file is +`/home/user/.openinspect/supervisor.log` (reachable via the session's code-server terminal while the +sandbox is alive, or through `boat exec`). + +## Common Issues + +### Snapshot Was Not Built + +When `sandbox_provider = "boat"`, Terraform builds the snapshot during `terraform apply`, keyed on a +hash of the runtime, image package and builder sources. To force a rebuild, change a hashed source +file. For a manual build, set `BOAT_BASE_SNAPSHOT` on the control plane to the exact name the build +printed. + +### Spawns Fail With `rate_limited` or `limit_reached` + +Boat limits sandbox starts per minute, hour and day, and concurrent sandboxes, per plan. Stopped +sandboxes do not count as concurrent. Raise the plan or pace session creation. + +### A Resumed Session Starts From a Fresh Checkout + +Boat deletes a stopped sandbox's disk after 14 days. The control plane then spawns a fresh sandbox +for the next prompt; uncommitted work from before the stop is gone. Push or open a pull request +before leaving a session idle for that long. + +### Missing Repository Access + +Repository access still comes from the configured GitHub App installation. If the dashboard shows no +repositories or a sandbox cannot clone a repo, check the GitHub App installation permissions before +debugging Boat. + +### LLM/API Key Problems + +Boat sandboxes get every model credential from Open-Inspect's secrets settings. If OpenCode reports +a model or provider error, confirm the key for the selected model is saved at a scope the session +inherits, and that the model is available for that account. + +## References + +- [Boat API v1](https://docs.boat.dev/api/v1) +- [Boat snapshots: stop and resume](https://docs.boat.dev/snapshots/stop) +- [Boat saved snapshots](https://docs.boat.dev/snapshots/templates) +- [Boat public URLs](https://docs.boat.dev/hosting) +- [Boat limits and errors](https://docs.boat.dev/limits) diff --git a/docs/CLAUDE_AGENT.md b/docs/CLAUDE_AGENT.md index 2a785038ee..d036e4e02f 100644 --- a/docs/CLAUDE_AGENT.md +++ b/docs/CLAUDE_AGENT.md @@ -18,7 +18,13 @@ sign in inside a sandbox. Every session runs on exactly one harness, chosen when the session is created and fixed for its lifetime (like the base branch). Child sessions inherit their parent's harness. Automations carry a -harness for the sessions they create. Bots and integrations create OpenCode sessions. +harness for the sessions they create. The Linear and GitHub integrations have a harness setting +(global and per repository, OpenCode by default); see [Linear sessions](#linear-sessions) and +[GitHub settings](integrations/GITHUB.md#models-and-instructions). The Slack integration has a +workspace setting (global only, OpenCode by default) and a per-user choice in App Home; see +[Slack sessions](#slack-sessions). All three preserve the selected harness: if it cannot run the +resolved model, the bot replies with an explanation and creates no session instead of falling back +to OpenCode. | Harness | Models | Anthropic authentication | Notes | | ---------------- | --------------------- | ------------------------------------------ | ------------------------------------- | @@ -33,6 +39,99 @@ An **installation default** Anthropic account only takes effect on Claude Agent bot and automation sessions on OpenCode keep using the API key, so setting a default never breaks sessions that cannot use it. +### Linear sessions + +**Settings > Integrations > Linear** has an **Agent harness** setting, globally and per repository +override; a repository harness override wins, otherwise it inherits the global harness, with +OpenCode used when neither sets one. Linear resolves the model (`model:*` label, user preference, +repository or global default, deployment `DEFAULT_MODEL`) without changing that harness. With Claude +Agent selected, a non-Anthropic model is refused: the bot posts an error activity in Linear and +creates no coding session. Choose a compatible `model:*` label, user preference, or default model, +or change the Linear integration harness, then delegate again. Labels and user preferences apply +only when allowed. For successful launches, the Linear activity names the harness: +`Creating coding session on (agent: Claude Agent, model: …)`. + +For both GitHub and Linear, explicitly selecting a restricted harness such as Claude Agent requires +a compatible default model at that same settings level (global or repository). A repository harness +override cannot use an inherited model to satisfy this requirement. Saving an incompatible pair at +the same level is rejected. Omitted repository harness and model fields still inherit individually +from the global settings. Save validation checks each explicit level, not the merged result; +cross-level mismatches are refused at launch rather than switched to OpenCode. + +Existing GitHub and Linear settings that select Claude Agent without a default model are not +automatically migrated. They remain readable, and a rejected save leaves the stored settings +unchanged. Before saving any edits, including unrelated settings, an administrator must open +**Settings > Integrations > GitHub** or **Linear** and choose a compatible Anthropic default model +at every global or repository level that explicitly selects Claude Agent. Setting only a global +model does not repair a repository's explicit Claude Agent selection. Alternatively, switch that +level to OpenCode, or remove the repository harness override to inherit the global settings. + +Linear sessions are unattended, so on Claude Agent they follow the **Automated authentication** +policy: with a default Claude account and that policy on **Use default**, Linear usage draws on the +connected subscription; otherwise it uses `ANTHROPIC_API_KEY`. Switching the setting affects only +new sessions; follow-ups on an existing issue session keep the harness it was created with. + +### Slack sessions + +A Slack session runs on the user's **Agent harness** choice in the Slack app's **Home** tab +(**Workspace default**, **OpenCode**, or **Claude Agent**), else on the **Agent harness** setting +under **Settings > Integrations > Slack**. That setting is global only, and unset means OpenCode. +The bot resolves the model as before: `!model` and `!reasoning` flags on the request that starts the +session, the user's App Home model and reasoning effort, the Slack **Default model**, deployment +`DEFAULT_MODEL`. + +Slack does not fall back to OpenCode. If the session's harness cannot run the resolved model, the +bot creates no session and, after its "Starting work..." message, replies in the thread: + +> Model "openai/gpt-5.4" cannot run on the Claude Agent harness. Start your request with `!model` +> and a model Claude Agent can run, or change your model or agent harness in the Slack app's Home +> tab. + +App Home lists only models the user's harness can run. If the user's current model cannot run on it, +the model picker shows "Choose a model" with no selection and says new requests are refused until a +model is chosen. The App Home "Currently using" summary names the harness. The "Starting work..." +acknowledgement never names the harness; it names the model and reasoning effort only when they +differ from the user's App Home defaults. + +When the workspace **Agent harness** is Claude Agent, a Slack **Default model** is required. The +settings form disables **Save** until one is chosen, and the control plane rejects saving Claude +Agent without one. "Use system default" is not offered for Claude Agent, because the settings page +cannot see the deployment default model, which may be one Claude Agent cannot run. As for Linear, +saving a harness with a model it cannot run is rejected, and the form only lists models the harness +can run. + +A thread keeps the harness its session was created with, so switching the setting affects only new +sessions. The control plane rejects a follow-up `!model` that the thread's harness cannot run, and +the bot posts that explanation in the thread with advice: + +> Model "openai/gpt-5.4" cannot run on the Claude Agent harness. A thread keeps the harness its +> session started on. Reply without `!model`, or start a new thread to use that model. + +A `!reasoning`-only follow-up keeps the thread's session model, as a follow-up without flags does, +even if an administrator has since disabled that model. The thread's harness was chosen to run that +model, so the follow-up always runs. + +Slack sessions are unattended, so on Claude Agent they follow the **Automated authentication** +policy. With a default Claude account and that policy on **Use default**, Slack usage draws on the +connected subscription. With **No account (API key)**, it uses `ANTHROPIC_API_KEY`. This also +applies when a user picks Claude Agent in App Home while the workspace harness is OpenCode, so set +**No account (API key)** to keep all Slack sessions on the key. + +If the bot cannot read the Slack settings, it treats the workspace harness as OpenCode; a user's App +Home harness choice still applies. An Anthropic model on OpenCode needs `ANTHROPIC_API_KEY`, so a +deployment that relies only on a connected Claude account sees those sessions fail. + +On Claude Agent, a Slack session loads the target repository's `CLAUDE.md` and `.claude/` settings +and hooks, and code the agent runs inherits the Anthropic credential (see +[How the credential reaches the sandbox](#how-the-credential-reaches-the-sandbox)). Slack users +choose the branch through App Home, so let Slack channels reach only repositories whose `.claude/` +configuration you trust on every branch. + +A Claude Agent session skips prebuilt images older than the Claude image floor +(`harnessMinimumGeneration` in the runtime manifest). After switching, a repository whose image +predates that floor starts its first Claude Agent sessions without it, so they are slower until the +image is rebuilt. + --- ## Setup @@ -180,6 +279,10 @@ fix instead. `CLAUDE_CONFIG_DIR` (`~/.openinspect/claude/skills`); repository `.claude/` settings, hooks and agents load through `setting_sources=["user","project"]`, the same trust boundary as the repository's `.openinspect/setup.sh` and `.opencode/` directory under OpenCode. +- **Privacy.** The harness disables Claude Code's commit, pull request, and session-link + attribution. It also disables nonessential Anthropic traffic, error reporting, feedback and + surveys, and both Anthropic and OpenTelemetry usage telemetry. Model requests and Open-Inspect's + own per-turn cost accounting are unaffected. - **Tools.** Open-Inspect's own tools are served to the Claude harness in-process as the `oi` MCP server: child sessions and `upload-media` always, `create-pull-request` when the session has a repository, and `slack-notify` when agent notifications are enabled for the repository. Session @@ -190,8 +293,11 @@ fix instead. groups the sub-agent's activity under it the same way. Background sub-agents would let the turn end before their work is done and deliver their findings on a later turn nobody reads; as a second guard, the harness ignores the result of any turn it did not submit. +- **Memory.** The child runs with `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`, so Claude Code's file-based + auto memory (the `memory/` directory under `CLAUDE_CONFIG_DIR` and its system-prompt section) is + off. Open-Inspect's memory tools are the agent's only memory system. - **Follow-ups queue.** Both harnesses hold follow-up prompts until the running turn completes. - **Image.** The sandbox image pins `claude-agent-sdk`, whose wheel bundles the `claude` binary. The - runtime manifest names the first generation that carries it under `harnessMinimumGeneration`, so a - Claude session never boots a prebuilt image from before that generation; OpenCode sessions keep - their images and snapshots, since the global compatibility floor did not move. + runtime manifest's `harnessMinimumGeneration` controls which prepared images new Claude sessions + can use. Older images and resumed snapshots may lack newer models until rebuilt; a model request + can fail on a sandbox whose bundled CLI does not support it. diff --git a/docs/CONTROL_PLANE_CONTAINER.md b/docs/CONTROL_PLANE_CONTAINER.md index 430db695d6..7feb011002 100644 --- a/docs/CONTROL_PLANE_CONTAINER.md +++ b/docs/CONTROL_PLANE_CONTAINER.md @@ -10,13 +10,12 @@ providers behave the same; only the platform adapters differ. ## What the stack contains -| Service | Image | Role | -| ------------ | ----------------------- | ------------------------------------------------------------------------------------- | -| `app` | built from this repo | The control plane: HTTP API, session WebSockets, cron jobs. Port 8787. | -| `minio` | `minio/minio` | S3-compatible object storage for media and backups. Console on port 9001. | -| `minio-init` | `minio/mc` | Creates the `media` and `backups` buckets, then exits. | -| `litestream` | `litestream/litestream` | Replicates the global store (`/data/global.db`) to the `backups` bucket every second. | -| `caddy` | `caddy` (profile `tls`) | Optional TLS termination for a public hostname. | +| Service | Image | Role | +| -------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------- | +| `app` | built from this repo | The control plane: HTTP API, session WebSockets, cron jobs. Port 8787. | +| `object-store` | `chrislusf/seaweedfs` | S3-compatible object storage for media and backups (SeaweedFS). Creates both buckets at startup. Port 9000. | +| `litestream` | `litestream/litestream` | Replicates the global store (`/data/global.db`) to the `backups` bucket every second. | +| `caddy` | `caddy` (profile `tls`) | Optional TLS termination for a public hostname. | The web app is not part of the stack. It stays on Vercel in production and runs with `next dev` locally, pointed at the container (see below). @@ -35,9 +34,10 @@ Prerequisites: Docker with Compose v2, a GitHub App and OAuth app as in Every variable is documented in place, and every value a boot cannot do without either ships with a working default or is listed here. Generate the three encryption keys — `TOKEN_ENCRYPTION_KEY`, `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` and `REPO_SECRETS_ENCRYPTION_KEY` — with - `openssl rand -base64 32` each. Generate a MinIO root password with `openssl rand -hex 16` and - set it as `MINIO_ROOT_PASSWORD`, `AWS_SECRET_ACCESS_KEY` and `LITESTREAM_SECRET_ACCESS_KEY`; the - stack refuses to start without it. The other MinIO and Litestream defaults work as they are. + `openssl rand -base64 32` each. Generate an object store root password with + `openssl rand -hex 16` and set it as `OBJECT_STORE_ROOT_PASSWORD`, `AWS_SECRET_ACCESS_KEY` and + `LITESTREAM_SECRET_ACCESS_KEY`; the stack refuses to start without it. The other object store and + Litestream defaults work as they are. The rest of the file boots as shipped. A GitHub App, an OAuth app and a sandbox provider are what the stack needs to do anything useful, but their variables are read at use rather than at boot, @@ -57,12 +57,13 @@ Prerequisites: Docker with Compose v2, a GitHub App and OAuth app as in The response reports the migrations applied, the resident sessions, and the state of the cron loop and alarm clock. Litestream logs `snapshot written` once the first snapshot is in the - `backups` bucket; the MinIO console at http://127.0.0.1:9001 shows both buckets. + `backups` bucket. Any S3 client pointed at http://127.0.0.1:9000 with the root credentials lists + both buckets. -The app's port and MinIO's ports are published on loopback only. `APP_BIND_ADDRESS` in `.env` moves -the app's port to another interface where something in front of the host restricts access, such as a -security group on AWS. Only the app reads `.env`; the sidecars receive the few variables they need -by name, never the app's secrets. +The app's port and the object store's S3 port are published on loopback only. `APP_BIND_ADDRESS` in +`.env` moves the app's port to another interface where something in front of the host restricts +access, such as a security group on AWS. Only the app reads `.env`; the sidecars receive the few +variables they need by name, never the app's secrets. Stop with `docker compose down`. The data volume survives; `docker compose down -v` deletes it. Compose gives the app 40 seconds to drain before killing it, which covers the host's 30-second @@ -209,13 +210,13 @@ docker compose -f docker-compose.yml -f docker-compose.aws.yml up -d It changes three things and nothing else. The app runs the image `CONTROL_PLANE_IMAGE` names — an ordinary `.env` entry, written from SSM like the rest, so changing the tag is a `terraform apply` and a restart rather than a new instance — rather than a build, because the instance has no -checkout. MinIO does not run, because S3 is the object store and Litestream's replica target. And -Caddy leaves the `tls` profile and starts with the rest, because TLS is not optional on a public -address. +checkout. The local object store does not run, because S3 is the object store and Litestream's +replica target. And Caddy leaves the `tls` profile and starts with the rest, because TLS is not +optional on a public address. -`.env` still has to carry `MINIO_ROOT_PASSWORD`: Compose interpolates the base file before it -applies an overlay, so that variable's `:?` guard fires whether or not MinIO is among the services -that end up running. The AWS deployment gives it an unused value. +`.env` still has to carry `OBJECT_STORE_ROOT_PASSWORD`: Compose interpolates the base file before it +applies an overlay, so that variable's `:?` guard fires whether or not the local object store is +among the services that end up running. The AWS deployment gives it an unused value. ## The smoke test @@ -239,15 +240,12 @@ and plays the bridge. Nothing reaches a cloud, so the smoke needs no credentials To run it beside a stack already holding the default ports, move its published ones: ```bash -SMOKE_APP_PORT=8798 SMOKE_MINIO_PORT=9010 SMOKE_MINIO_CONSOLE_PORT=9011 \ - SMOKE_FAKE_MODAL_PORT=9910 scripts/compose-smoke.sh +SMOKE_APP_PORT=8798 SMOKE_OBJECT_STORE_PORT=9010 SMOKE_FAKE_MODAL_PORT=9910 \ + scripts/compose-smoke.sh ``` ## Not yet available on the container -- Repository image builds: the finalization step is a background job (`src/jobs.ts`), and the jobs - seam has no container implementation yet, so build triggers fail before registration or provider - startup. Sessions start from the base sandbox image. - The GitHub autofix queue and the Slack and Linear bots: the bots remain Cloudflare Workers and reach a container-hosted control plane over HTTPS once that transport lands. @@ -260,7 +258,7 @@ docker run --rm -p 127.0.0.1:8787:8787 --env-file .env -e LITESTREAM_BUCKET= \ ``` `LITESTREAM_BUCKET=` turns the restore-on-empty step off: outside the compose network there is no -`minio` host, and the entrypoint would otherwise try to reach it before the host boots. Point +`object-store` host, and the entrypoint would otherwise try to reach it before the host boots. Point `LITESTREAM_ENDPOINT` at a reachable bucket instead to keep the restore. The entrypoint refuses a plain-`http://` endpoint unless `OBJECT_STORE_ALLOW_HTTP=true`, the same opt-in the host applies to its own object store, and removes a stray `global.db-wal` or `-shm` left without its database so it diff --git a/docs/DEBUGGING_PLAYBOOK.md b/docs/DEBUGGING_PLAYBOOK.md index 7df1c58579..0ad5083199 100644 --- a/docs/DEBUGGING_PLAYBOOK.md +++ b/docs/DEBUGGING_PLAYBOOK.md @@ -62,6 +62,106 @@ per-hop `request_id`. To trace a full request: filter by `trace_id` across all three services, or narrow by `session_id` + `message_id` for a specific prompt run. +## Latency and Usage Baselines + +Measure distributions before changing providers, warm pools, or idle timeouts. These are server-side +latency signals, not browser-render timings. Reuse the existing `prompt.complete` fields rather than +treating provider allocation or a connected WebSocket as user readiness. + +### Timing boundaries + +| Signal | Start and end | Interpretation | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `prompt.complete.total_duration_ms` | Message creation to control-plane receipt of accepted `execution_complete` | Queue plus processing; excludes browser delivery/rendering and completion callbacks | +| `prompt.complete.queue_duration_ms` | Message creation to its stored processing start | Includes boot waiting when the prompt arrived before readiness | +| `prompt.complete.processing_duration_ms` | Stored processing start to completion receipt | Includes dispatch preparation/transport, not just model execution | +| `sandbox.ready.generation_to_ready_ms` | Control-plane generation reservation/resume (`sandbox.created_at`) to receipt of the accepted runtime `ready` | Includes startup work and transport delay after reservation; excludes earlier admission work. Provider bookkeeping or preservation holds can still delay prompt dispatch | +| `sandbox.boot_progress.elapsed_ms` | Runtime-local phase scope start to completed/failed phase | Existing sync/setup/start/skills/memory/harness measurements, relayed without subtracting clocks | +| `bridge.harness_attach.duration_seconds` | Entry to early-connect attachment through local readiness publication, or failure/cancellation | Monotonic Python time; includes harness construction/open, session-resume attempt, signing retries and ready-send attempt. Excludes waiting for the supervisor's harness phase and relay polling | +| `http.request.sql_total_ms` | Sum of caller-observed `first`/`run`/`all`/`batch` attempts | Includes failed attempts. A batch is one record; concurrent query durations may overlap | + +The readiness boundaries use only control-plane timestamps. Attachment durations use only the +bridge's monotonic clock. Never subtract a runtime event's epoch-seconds `timestamp` from a +control-plane timestamp, or add nested/overlapping provider, supervisor, phase and readiness +durations. TypeScript durations are milliseconds; the new Python attachment duration is seconds. +Existing Python `duration_ms` logs and wire `elapsedMs` retain their explicit millisecond units. + +`WARMING` returned by Modal means allocation/access discovery, not readiness. `sandbox.startup` +finishes supervisor work, not bridge attachment. Cached `repo_image` and `snapshot_restore` boots +still synchronize repositories and run start hooks; they skip setup hooks. Snapshot sync fetches +without resetting the preserved checkout. Cached sync failures may warn and continue, so compare +warning/failure rates alongside successful latency. `ready_sent=false` on successful attachment +means local readiness was reached but delivery was buffered, not acknowledged by the control plane. +Even `ready_sent=true` is not a receipt acknowledgment; use `sandbox.ready` for that boundary. + +### Correlation and coverage + +- Use `session_id` plus `message_id` for prompt completion, and `session_id`, `sandbox_id`, plus + `generation_created_at_ms` for control-plane readiness. The generation timestamp distinguishes + in-place resumes that retain the logical sandbox ID; it is not a provider allocation timestamp. +- Readiness timing is emitted only after the existing guarded readiness transition. Ordinary ready + replay/reconnects do not emit another sample. For exported logs, retain the first accepted + readiness per generation; later recovery transitions are not new allocations. +- Boot phases retain the existing sequence high-water-mark guard. Join by sandbox and boot sequence, + not sequence alone. The relay is best-effort and resends only the latest phase on reconnect; + missing phases are unknown, not zero-duration work. +- Attachment emits once per attempt, including errors and cancellation, and is not rerun on a socket + reconnect. A bridge process restart can perform another attempt in the same sandbox; keep those + retries rather than treating them as independent boots. Legacy late-connect mode does not use this + attachment timer. +- Use only duration-bearing `prompt.complete` events. `outcome=already_stopped` includes replay and + other nonmatching completions and carries no timing. Cancellation/timeout paths do not all emit + this event, and a projection failure after committing completion can lose the timed log. Logs are + not an exactly-once accounting ledger; report missing observations and failures. +- First assistant text, first tool activity, and client-rendered output are not measured by these + signals. Do not label completion or readiness as time-to-first-token or browser-visible latency. + +### Operator workflow + +For Cloudflare deployments, the existing log query tool preserves these fields under `source`: + +```bash +node scripts/cf-logs.ts --session SESSION_ID --json +node scripts/cf-logs.ts --search sandbox.ready --json +node scripts/cf-logs.ts --search prompt.complete --json +``` + +Use the deployed runtime's log export for supervisor/bridge records; `cf-logs.ts` does not fetch +sandbox stdout. Join `sandbox.startup.boot_mode` (`fresh`, `repo_image`, `snapshot_restore`) to +readiness and phases by session/sandbox identity. Exclude `build` mode and keep missing/ambiguous +path matches as unknown, especially across retained-runtime resumes. Do not infer a cache hit from a +snapshot merely existing in the database. Use runtime `prompt.start` records (message and sandbox +correlation) to associate completions with a sandbox, not just the most recent startup for the +session. Where generation attribution is ambiguous, leave it unknown; already-ready prompts form a +separate cohort and should not inherit startup wait. + +For the same observation window, compare sample count, p50/p95/p99, missing-sample count and failure +rate by startup path and harness/provider. Start with queue versus processing duration, then +readiness and individual phase/attachment durations to explain long queue waits. Keep retry attempts +visible. Use low-cardinality cohort dimensions only; session, message, sandbox, repository and +generation identifiers belong in logs/traces, never metric labels. Do not export prompt text, tool +arguments, environment variables, credentials or signed URLs into measurement datasets. + +### Usage is not infrastructure billing + +Reuse the persisted `step_usage` records and existing budget/session cost projections instead of +summing raw replayable `step_finish` logs. Stable `stepId` records are upserted (including usage +corrections); legacy timestamp fallback has weaker identity. Token fields and reported costs can be +null when unavailable. Preserve unknown values, keep subtask attribution, and do not add a +cumulative `messageCostUsd` to per-step costs. Model/harness-reported USD is not a provider invoice. + +The HTTP SQL summary reports engine duration/rows only when the engine returned them. Failed +operations now contribute caller time/count without inventing engine time or rows; `first()` also +has no engine metadata. Neither absence nor a thrown write proves zero billed work. + +No new infrastructure dollar estimate or residency counter is introduced here. Existing bridge +connection durations measure transport connectivity, not allocation lifetime. Allocation-to-ready +time, configured idle/timeout limits, and a stop request do not establish actual residency or billed +cost. Use provider-confirmed allocation/termination or billing exports for those questions, and keep +orphaned/retained allocations and missing termination observations explicit. Compare reliable +reported usage per completed prompt with latency cohorts, separately from provider billing; do not +convert sandbox residency into dollars without an authoritative billing model. + ## Error Shape All services use the same error fields: @@ -130,29 +230,46 @@ One `image_build.*` vocabulary covers both scope kinds; events carry `scope_kind #### Session Durable Object (`component: "session-do"`) -| Event | Level | Key Fields | Description | -| ---------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -| `do.request` | info | `http_method`, `http_path`, `http_status`, `duration_ms`, `outcome` | One per DO internal route call | -| `ws.connect` | info, warn | `ws_type` (sandbox\|client), `outcome`, `reject_reason`, `sandbox_id`, `participant_id`, `duration_ms` | WebSocket lifecycle | -| `prompt.enqueue` | info | `message_id`, `source`, `author_id`, `user_id`, `model`, `content_length`, `has_attachments`, `queue_position` | Message queued | -| `prompt.dispatch` | info | `message_id`, `outcome`, `reason`, `model`, `has_sandbox_ws`, `queue_wait_ms` | Message sent to sandbox | -| `prompt.complete` | info, warn | `message_id`, `outcome`, `total_duration_ms`, `processing_duration_ms`, `queue_duration_ms` | Prompt run finished | -| `callback.complete_delivery` | info, error | `session_id`, `message_id`, `source`, `outcome`, `duration_ms`, `attempts`, `retries`, `http_status`, `reject_reason` | Completion callback delivery result | -| `callback.started_delivery` | info, error | `session_id`, `message_id`, `outcome`, `duration_ms`, `attempts`, `retries`, `http_status`, `reject_reason` | Linear start-callback delivery result | +| Event | Level | Key Fields | Description | +| ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `do.request` | info | `http_method`, `http_path`, `http_status`, `duration_ms`, `outcome` | One per DO internal route call | +| `ws.connect` | info, warn | `ws_type` (sandbox\|client), `outcome`, `reject_reason`, `sandbox_id`, `participant_id`, `duration_ms` | WebSocket lifecycle | +| `prompt.enqueue` | info | `message_id`, `source`, `author_id`, `user_id`, `model`, `content_length`, `has_attachments`, `queue_position` | Message queued | +| `prompt.dispatch` | info | `message_id`, `outcome`, `reason`, `boot_seq`, `phase`, `phase_status`, `repo_owner`, `repo_name`, `elapsed_ms`, `warning`, `model` | Message sent to sandbox, or deferred | +| `prompt.complete` | info, warn | `message_id`, `outcome`, `total_duration_ms`, `processing_duration_ms`, `queue_duration_ms` | Prompt run finished | +| `callback.complete_delivery` | info, error | `session_id`, `message_id`, `source`, `outcome`, `duration_ms`, `attempts`, `retries`, `http_status`, `reject_reason` | Completion callback delivery result | +| `callback.started_delivery` | info, error | `session_id`, `message_id`, `outcome`, `duration_ms`, `attempts`, `retries`, `http_status`, `reject_reason` | Linear start-callback delivery result | +| `sandbox.boot_progress` | info | `sandbox_id`, `boot_seq`, `phase`, `phase_status`, `repo_owner`, `repo_name`, `elapsed_ms`, `warning` | Boot phase reported by the runtime | +| `sandbox.ready` | info | `sandbox_id`, `generation_created_at_ms`, `generation_to_ready_ms`, `harness` | Accepted runtime readiness transition | + +`prompt.dispatch` with `outcome="deferred"` and `reason="sandbox_booting"` means a bridge is +attached but its boot has not reached `ready`: nothing is spawned and nothing is sent, and the +runtime's `ready` event (`sandbox.ready`) pumps the queue. `sandbox.boot_progress` is logged once +per `boot_seq`; the copy the bridge resends on reconnect is dropped at debug level +(`sandbox.boot_progress_repeated`). #### Lifecycle Manager (`component: "lifecycle-manager"`) Dashboards and alerts must migrate `sandbox.spawned` and `sandbox.spawn_failed` to `sandbox.spawn` -outcomes, and `sandbox.restored` to `sandbox.restore` outcomes. - -| Event | Level | Key Fields | Description | -| ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------- | -| `sandbox.spawn` | info, error | `outcome`, `duration_ms`, `sandbox_id`, `expected_sandbox_id`, `provider_object_id`, `repo_owner`, `repo_name`, `error` | Spawn completed | -| `sandbox.restore` | info, error | `outcome`, `duration_ms`, `snapshot_image_id`, `sandbox_id`, `provider_object_id`, `repo_owner`, `repo_name`, `error` | Restore completed | -| `sandbox.snapshot` | info | `reason`, `provider_object_id` | Snapshot attempt started | -| `sandbox.snapshot_saved` | info | `image_id`, `reason` | Snapshot saved | -| `sandbox.heartbeat_stale` | warn | `last_heartbeat_ms`, `threshold_ms` | Heartbeat missed | -| `sandbox.timeout` | info | `last_activity`, `timeout_ms` | Inactivity timeout reached | +outcomes, `sandbox.restored` to `sandbox.restore` outcomes, and `sandbox.timeout` to +`sandbox.inactivity_timeout` (now emitted for every provider). + +| Event | Level | Key Fields | Description | +| ------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `sandbox.spawn` | info, error | `outcome`, `duration_ms`, `sandbox_id`, `expected_sandbox_id`, `provider_object_id`, `repo_owner`, `repo_name`, `error` | Spawn completed | +| `sandbox.restore` | info, error | `outcome`, `duration_ms`, `snapshot_image_id`, `sandbox_id`, `provider_object_id`, `repo_owner`, `repo_name`, `error` | Restore completed | +| `sandbox.snapshot` | info | `reason`, `modal_object_id` | Snapshot attempt started | +| `sandbox.snapshot_saved` | info | `image_id`, `reason` | Snapshot saved | +| `sandbox.heartbeat_stale` | warn | `last_heartbeat_ms`, `threshold_ms`, `sandbox_status` | Heartbeat missed | +| `sandbox.inactivity_timeout` | info | `spawn_source`, `timeout_ms`, `idle_ms` | Inactivity stop decided | +| `sandbox.connecting_timeout` | warn | `elapsed_ms`, `timeout_ms` | Bridge never connected within 240 s of creation | +| `sandbox.boot_budget` | warn | `elapsed_ms`, `timeout_ms`, `boot_seq`, `phase`, `phase_status`, `repo_owner`, `repo_name`, `warning` | Connected sandbox outlived `SANDBOX_BOOT_TIMEOUT_MS`; generation fenced | +| `sandbox.fatal_runtime_error` | warn | `sandbox_status`, `error` | Runtime reported a fatal error; sandbox failed | +| `sandbox.failed_reconnected` | info | `sandbox_id` | Watchdog-failed boot connected late; resumed as `connecting` | +| `sandbox.circuit_breaker_open` | warn | `failure_count`, `wait_time_ms` | Spawn refused after repeated boot failures | + +Where `msg` is prose (`"Boot budget exceeded"`, `"Heartbeat stale"`), the stable identifier is in +`event`. #### Provider Clients @@ -182,24 +299,34 @@ outcomes, and `sandbox.restored` to `sandbox.restore` outcomes. #### Supervisor (`component: "supervisor"`) -| Event | Level | Key Fields | Description | -| ------------------ | ----- | -------------------------------------------------------------------------------------------------------- | ------------------------------ | -| `sandbox.startup` | info | `repo_owner`, `repo_name`, `restored_from_snapshot`, `git_sync_success`, `opencode_ready`, `duration_ms` | One per sandbox boot | -| `supervisor.start` | info | `repo_owner`, `repo_name` | Supervisor process started | -| `supervisor.error` | error | `exc` | Unhandled supervisor error | -| `supervisor.fatal` | error | `message` | Fatal error, sandbox will exit | +| Event | Level | Key Fields | Description | +| ------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| `sandbox.startup` | info | `repo_owner`, `repo_name`, `boot_mode`, `restored_from_snapshot`, `from_repo_image`, `git_sync_success`, `setup_success`, `start_success`, `harness`, `opencode_ready`, `duration_ms`, `outcome` | Supervisor startup complete; bridge attachment may still be pending | +| `supervisor.start` | info | `repo_owner`, `repo_name` | Supervisor process started | +| `supervisor.error` | error | `error_type`, `error_message`, `error_stack` | Unhandled supervisor error | +| `supervisor.fatal` | error | `error_message`, `boot_phase` | Fatal error, sandbox will exit | +| `supervisor.boot_warning` | warning | `scope`, `warning_message`, `repo_owner`, `repo_name` | Non-fatal hook failure, relayed as a `warning` event | +| `supervisor.boot_cancelled` | info | `reason` | Boot cancelled by a `shutdown` or a fenced token | +| `bridge.deterministic_failure` | error | `exit_code`, `cause` | Harness could not open; reported as a fatal `harness` phase | +| `bridge.crash` | error | `exit_code`, `restart_count` | Bridge exited abnormally; restarted with backoff, during boot too | #### Bridge (`component: "bridge"`) -| Event | Level | Key Fields | Description | -| --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `bridge.connect` | info | `outcome` | WebSocket connected to control-plane | -| `bridge.disconnect` | info, warn | `reason`, `ws_close_code`, `connection_duration_seconds`, `total_connected_duration_seconds`, `connection_count`, `reconnect_count`, `reconnect_attempt_count` | WebSocket disconnected with lifetime aggregates | -| `bridge.reconnect` | info | `attempt`, `reconnect_attempt_count`, `delay_s` | Reconnection attempt | -| `bridge.run_complete` | info | `outcome`, `connection_count`, `reconnect_count`, `reconnect_attempt_count`, `total_connected_duration_seconds` | Bridge process exited with aggregate connection stats | -| `prompt.start` | info | `message_id`, `model` | Prompt processing started | -| `prompt.run` | info | `message_id`, `model`, `outcome`, `duration_ms` | Prompt processing completed (wide) | -| `prompt.error` | error | `message_id`, `exc` | Prompt processing failed | +`bridge.connect` fires before the repository boots: the bridge is transport-only until the `harness` +phase completes, then attaches the harness and sends `ready`. + +| Event | Level | Key Fields | Description | +| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bridge.connect` | info | `outcome` | WebSocket connected to control-plane | +| `bridge.disconnect` | info, warning | `reason`, `ws_close_code`, `connection_duration_seconds`, `total_connected_duration_seconds`, `connection_count`, `reconnect_count`, `reconnect_attempt_count` | WebSocket disconnected with lifetime aggregates | +| `bridge.reconnect` | info | `attempt`, `reconnect_attempt_count`, `delay_s` | Reconnection attempt | +| `bridge.run_complete` | info | `outcome`, `connection_count`, `reconnect_count`, `reconnect_attempt_count`, `total_connected_duration_seconds` | Bridge process exited with aggregate connection stats | +| `bridge.harness_attach` | info | `harness`, `outcome`, `duration_seconds`, `ready_sent` | Early-connect attachment attempt, including failure/cancellation; replaces `bridge.harness_attached` | +| `bridge.harness_attach_failed` | error | `error_type`, `error_message`, `error_stack` | Harness attach failed; the bridge exits and the supervisor restarts it, reporting `Bridge crashed N times` only once the restart budget is spent. A deterministic `HarnessStartError` skips the retries and is reported at once as a fatal `harness` phase — see `bridge.deterministic_failure` | +| `bridge.command_refused_while_booting` | warning | `cmd_type` | Command arrived before `ready`: `snapshot` and `refresh_diff` are dropped, `push` is answered with `push_error` | +| `prompt.start` | info | `message_id`, `model` | Prompt processing started | +| `prompt.run` | info | `message_id`, `model`, `outcome`, `duration_ms` | Prompt processing completed (wide) | +| `prompt.error` | error | `message_id`, `error_type`, `error_message`, `error_stack` | Prompt processing failed | #### Git Operations (`component: "bridge"` / `"supervisor"`) @@ -346,35 +473,84 @@ Key fields to check: ``` # 1. Did the sandbox spawn? -service="control-plane" msg="sandbox.spawn" session_id="" -service="control-plane" msg="sandbox.spawned" session_id="" +service="control-plane" event="sandbox.spawn" session_id="" # 2. Did the provider create it? Modal example: service="modal-infra" msg="sandbox.create" sandbox_id="" -# 3. Did startup succeed? -service="modal-infra" msg="sandbox.startup" sandbox_id="" - -# 4. Did the bridge connect? +# 3. Did the bridge connect? It connects before the repository boots, so this +# should follow 2 within seconds. service="modal-infra" msg="bridge.connect" sandbox_id="" -# 5. Did the control-plane accept the WebSocket? +# 4. Did the control-plane accept the WebSocket? service="control-plane" msg="ws.connect" ws_type="sandbox" session_id="" ``` Check `outcome` and `reject_reason` on `ws.connect`. Common reasons: - `sandbox_id_mismatch` — sandbox was replaced (stale sandbox reconnecting) -- `token_mismatch` — auth token doesn't match +- `token_mismatch` — auth token doesn't match (a fenced generation lands here) - `sandbox_stopped` — session already terminated +No `bridge.connect` within 240 s of creation ends in `sandbox.connecting_timeout`. Once `ws.connect` +succeeds the sandbox is `connecting` and the boot is under way; see the next scenario. + +### "Why is the sandbox stuck booting?" + +``` +# 1. Which phase is it in? The latest line is the current phase. +service="control-plane" event="sandbox.boot_progress" session_id="" + | fields boot_seq, phase, phase_status, repo_owner, repo_name, elapsed_ms, warning + +# 2. Did a hook fail without ending the boot? +service="modal-infra" event="supervisor.boot_warning" sandbox_id="" + +# 3. Did the boot finish? +service="modal-infra" event="sandbox.startup" sandbox_id="" +service="control-plane" event="sandbox.ready" session_id="" + +# 4. Is a prompt waiting on it? +service="control-plane" event="prompt.dispatch" outcome="deferred" reason="sandbox_booting" +``` + +Phases run `starting` → `sync` → `setup` (fresh boots only) → `start` → `skills` → `harness`, with +`setup` and `start` reported once per repository. `started` means that phase's work is under way, +which is not always a script: `sync`, `skills` and `harness` carry the same status, and `setup` and +`start` report it even for a repository that has no such hook. Inside the sandbox the same lines are +in `/tmp/oi-boot-events.jsonl`, whose first entry is `sync` — the bridge synthesises `starting` +itself when its socket opens and never writes it to the file. + +Three things end a boot that never reaches `ready`: + +- `sandbox.heartbeat_stale` with `sandbox_status="connecting"`: the bridge stopped heartbeating for + 90 s. The sandbox goes `stale`, counts as a boot failure, and is stopped without a snapshot, so a + half-booted filesystem never becomes the restore point. +- `sandbox.boot_budget`: `SANDBOX_BOOT_TIMEOUT_MS` (default 30 minutes) elapsed since creation. The + control plane sends `shutdown`, fences the generation, fails the row, and fails the waiting prompt + with the phase text (`Sandbox boot exceeded 30 minutes while running setup.sh for acme/api…`). +- `sandbox.fatal_runtime_error`: the runtime reported a fatal error (clone failed, the primary + repository's `start.sh` failed, the harness would not open). The report retains the phase and + error metadata, and repository metadata when applicable. Hook stdout and stderr are discarded + rather than collected or shown, including for a fatal `start.sh` failure. + +Fencing revokes a generation for good: the sandbox token hash is blanked, the socket id cleared, and +`fenced=1` set on the row. The runtime's next authenticated call or reconnect is refused, the bridge +exits cleanly, and the supervisor logs `supervisor.boot_cancelled` and stops the sandbox. A fenced +`failed` row never becomes `ready`, even if a late `ready` arrives. A connect-watchdog failure +fences only when the provider can stop the sandbox; otherwise the boot may still connect later, +which is logged as `sandbox.failed_reconnected` and resumes as `connecting`. A fenced +connect-watchdog failure re-drives the pending prompt onto a replacement sandbox right away. If +those timeouts open the circuit breaker, or the provider stop fails (a fenced generation is replaced +only after a confirmed stop), that prompt fails instead. + ### "Why did a sandbox spawn fail?" ``` -service="control-plane" msg="sandbox.spawn_failed" session_id="" +service="control-plane" event="sandbox.spawn" outcome="error" session_id="" ``` -Check the `error_type` and `error_message`. Then look at the selected provider side. Modal example: +Check `error_type` and `error_message` when present. If the caught value was not an `Error`, inspect +`error`, which contains `String(error)`. Then look at the selected provider side. Modal example: ``` service="modal-infra" msg="sandbox.create" outcome="error" @@ -386,6 +562,19 @@ If the circuit breaker opened: service="control-plane" component="lifecycle-manager" msg="Circuit breaker open" ``` +The breaker counts attempts that fail before a prompt is dispatched to the sandbox: a +`sandbox.spawn` error the provider classified as permanent (or that had no classification; transient +errors are not counted), a `sandbox.connecting_timeout`, a `sandbox.boot_budget`, a +`sandbox.heartbeat_stale` while still `spawning` or `connecting`, or a fatal runtime report +(`sandbox.fatal_runtime_error`). Dispatching a prompt clears the count; a connected bridge on its +own does not. A fatal runtime report is the one failure that is not confined to the pre-dispatch +window: it is recorded unconditionally, so a sandbox that dies after its prompt was dispatched +starts a fresh streak at one. The five-minute window is measured as the idle gap between the +previous failure and the start of the attempt that failed (the sandbox row's `created_at`), not +between the failures themselves: a chain of automatic re-drives has no idle gap and opens the +breaker on the third failure however long each boot ran, while a user who returns after an hour +starts a fresh streak. Find the failures that tripped it by searching the session for those events. + ### "Why did snapshot restore fail?" ``` @@ -432,7 +621,7 @@ level="error" | group by error_type, service, msg | count ### "Slow sandbox spawns" ``` -service="control-plane" msg="sandbox.spawned" | where duration_ms > 30000 +service="control-plane" event="sandbox.spawn" | where duration_ms > 30000 ``` Or on the provider side. Modal example: @@ -441,6 +630,13 @@ Or on the provider side. Modal example: service="modal-infra" msg="sandbox.create" | where duration_ms > 30000 ``` +Which boot phase is slow: + +``` +service="control-plane" event="sandbox.boot_progress" phase_status="completed" + | where elapsed_ms > 300000 | group by phase, repo_owner, repo_name | count +``` + ### "Prompt failures by model" ``` diff --git a/docs/E2B_SANDBOX_PROVIDER.md b/docs/E2B_SANDBOX_PROVIDER.md index 488bafe56c..d1a7832053 100644 --- a/docs/E2B_SANDBOX_PROVIDER.md +++ b/docs/E2B_SANDBOX_PROVIDER.md @@ -54,7 +54,7 @@ E2B sandboxes boot from a **template** image that contains: - the Open-Inspect sandbox runtime (`packages/sandbox-runtime`, staged into `/app`) - OpenCode and the OpenCode plugin dependencies -- Python 3.12 and Node 22 runtimes +- Python 3.12 and Node 24 runtimes - `code-server`, `agent-browser`, and browser/terminal tooling used by the agent runtime - GitHub CLI and a Git credential helper diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 3217f5ca96..682d59bac8 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -6,20 +6,74 @@ This guide walks you through deploying your own instance of Open-Inspect using T > [SETUP_GUIDE.md](./SETUP_GUIDE.md). > **Important**: This system is designed for **single-tenant deployment only**. All users share the -> same GitHub App credentials and can access any repository the App is installed on. See the +> deployment's GitHub App installation and workspace catalog. GitHub sandbox credentials reach only +> the session's repositories, further restricted by current grants for team-owned sessions. See the > [Security Model](../README.md#security-model-single-tenant-only) for details. +### Sandbox Repository Access + +GitHub credentials issued to a sandbox cover its primary repository and session members, including +the repository snapshot copied from an environment at creation. Workspace-owned sessions are scoped +the same way. For team-owned sessions, repositories no longer covered by the team's current grants +are removed from the next credential scope. An installation grant retains all session members but +does not grant the sandbox access to other repositories in the installation. + +Private submodules, repository-backed private dependencies, and setup scripts that clone sibling +repositories now fail unless those repositories are included in the session's environment. Team +sessions also need grants for those members. Add the dependencies to the environment before creating +the session; editing an environment does not expand an existing session's repository snapshot. + +Sessions with no repositories, no remaining granted members, or unresolved repository IDs receive no +token. Scopes with more than 500 unique IDs are refused. Repository image builds use that repository +alone, while environment image builds use their member repositories and the environment owner's +current grants. Metadata and catalog calls retain installation-wide credentials. GitLab deployments +still use one deployment-wide PAT and cannot provide this credential boundary. + +Removing a grant does not revoke a credential already issued to a sandbox. Installation tokens +remain valid until expiry, and credential helpers refresh shortly before expiry. Legacy session or +environment rows without numeric repository IDs require an identity-matching cached repository +catalog; loading the repository list populates that cache. Credentials fail closed if an ID cannot +be resolved from it. + --- +## Teams Enforcement + +Cloudflare production Terraform's `teams_enforcement` sets the control-plane `TEAMS_ENFORCEMENT` +value. Accepted values are `off`, `shadow`, and `on`, with **`shadow` as the default**. For a fresh +deployment, explicitly set `teams_enforcement = "on"` in `terraform.tfvars` (as in Step 5) so the +control plane runs with `TEAMS_ENFORCEMENT=on` and enforces non-private Team-visibility reads. AWS +production is separate and defaults to `shadow`: add `TEAMS_ENFORCEMENT = "on"` to its `config` map +in `terraform/environments/aws-production/terraform.tfvars` to opt in, then apply Terraform and +restart the service as described in [AWS Bring-Up](AWS_BRING_UP.md). Do not assume a deployed +instance has full team read isolation simply because it includes Teams. + +For an existing deployment, review its production `shadow_denied:*` authorization audit entries +before opting into `on`. Resolve unexpected would-deny decisions and verify membership, repository +grants, and bot bindings before changing the deployed value. A default flip remains gated on that +production audit review; this guide does not establish that the gate has passed. Runtime and +Terraform defaults remain `shadow`, independently of the fresh-deployment opt-in shown here. + +Private-session access and current owning-team membership for non-read session actions remain +enforced in every mode. Environment/automation ownership, repository grants, and team-secret checks +also remain enforced. See [Enforcement and Access Paths](AUTH.md#enforcement-and-access-paths). + +Separately, administrators can enable `requireTeamOnCreate` in **Settings > Teams**. It defaults off +and requires a team for new sessions, environments, and automation definitions, including teamless +bot session creation requests. It does not migrate existing workspace resources or prevent existing +workspace automations from running when their runtime authorization checks pass. New teams start +without repository grants; configure named or installation-wide grants before selecting +repositories. + ## Overview Open-Inspect uses Terraform to automate deployment across multiple cloud providers: -| Provider | Purpose | What Terraform Creates | -| ---------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| **Cloudflare** | Control plane, session state | Workers, KV namespaces, Durable Objects, D1 Database | -| **Vercel** _or_ **Cloudflare Workers** | Web application | Project + env vars (Vercel) _or_ Worker via OpenNext (Cloudflare) | -| **Modal**, **Daytona**, **Vercel Sandboxes**, **OpenComputer**, _or_ **E2B** | Sandbox execution infrastructure | Modal app deployment, Daytona/Vercel API config, OpenComputer template/API config, _or_ E2B template/API config | +| Provider | Purpose | What Terraform Creates | +| -------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| **Cloudflare** | Control plane, session state | Workers, KV namespaces, Durable Objects, D1 Database | +| **Vercel** _or_ **Cloudflare Workers** | Web application | Project + env vars (Vercel) _or_ Worker via OpenNext (Cloudflare) | +| **Modal**, **Daytona**, **Vercel Sandboxes**, **E2B**, _or_ **Boat** | Sandbox execution infrastructure | Modal app deployment, Daytona/Vercel API config, E2B template/API config, _or_ Boat snapshot/API config | > **Web platform choice**: Set `web_platform` in your `terraform.tfvars` to `"vercel"` (default) or > `"cloudflare"`. The Cloudflare option deploys the Next.js app as a Cloudflare Worker using @@ -28,27 +82,36 @@ Open-Inspect uses Terraform to automate deployment across multiple cloud provide **Your job**: Create accounts, gather credentials, and configure one file (`terraform.tfvars`). **Terraform's job**: Create all infrastructure and configure services. +**How this guide is organized**: Steps 1–10 are the minimal deploy path: one web platform, the +default Modal sandbox provider, a GitHub App for repository access and sign-in, and the Slack, +Linear, and GitHub bots turned off. The [Optional Sections](#optional-sections) after Step 10 cover +alternative sandbox providers, Google login, Slack, Linear, the GitHub bot, a custom domain, CI/CD, +branding, updating, and troubleshooting. + --- ## Prerequisites ### Required Accounts -Create accounts on these services before continuing: - -| Service | Purpose | -| --------------------------------------------------------- | --------------------------------------------------------------- | -| [Cloudflare](https://dash.cloudflare.com) | Control plane hosting (+ web app if using Cloudflare platform) | -| [Vercel](https://vercel.com) _(optional)_ | Web application hosting (only if `web_platform = "vercel"`) | -| [Modal](https://modal.com) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "modal"` | -| [Daytona](https://app.daytona.io) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "daytona"` | -| [Vercel Sandboxes](https://vercel.com) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "vercel"` | -| [OpenComputer](https://app.opencomputer.dev) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "opencomputer"` | -| [E2B](https://e2b.dev) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "e2b"` | -| [GitHub](https://github.com/settings/developers) | OAuth + repository access | -| [Anthropic](https://console.anthropic.com) | Claude API | -| [Slack](https://api.slack.com/apps) _(optional)_ | Slack bot integration | -| GitHub App Webhooks _(optional)_ | GitHub bot (PR reviews) | +Create accounts on these services before continuing. Rows marked _(optional)_ are only needed for +the matching optional section: + +| Service | Purpose | +| ------------------------------------------------------- | --------------------------------------------------------------- | +| [Cloudflare](https://dash.cloudflare.com) | Control plane hosting (+ web app if using Cloudflare platform) | +| [Vercel](https://vercel.com) _(optional)_ | Web application hosting (only if `web_platform = "vercel"`) | +| [Modal](https://modal.com) | Sandbox infrastructure (default `sandbox_provider = "modal"`) | +| [GitHub](https://github.com/settings/developers) | OAuth + repository access | +| [Anthropic](https://console.anthropic.com) _(optional)_ | Claude API (the Slack/Linear bot classifier's default provider) | +| [Bedrock](https://aws.amazon.com/bedrock) _(optional)_ | Alternative Claude host for the Slack/Linear bot classifier | +| [Daytona](https://app.daytona.io) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "daytona"` | +| [Vercel Sandboxes](https://vercel.com) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "vercel"` | +| [E2B](https://e2b.dev) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "e2b"` | +| [Boat](https://boat.dev) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "boat"` | +| [Slack](https://api.slack.com/apps) _(optional)_ | Slack bot integration | +| [Linear](https://linear.app) _(optional)_ | Linear Agent integration | +| GitHub App Webhooks _(optional)_ | GitHub bot (PR reviews) | ### Required Tools @@ -56,8 +119,11 @@ Create accounts on these services before continuing: # Terraform (1.14.0+; see terraform/environments/production/versions.tf) brew install terraform -# Node.js (22+) -brew install node@22 +# Node.js (24+). node@24 is keg-only, so put it on PATH (add the export to +# your shell profile to keep it across sessions). +brew install node@24 +export PATH="$(brew --prefix node@24)/bin:$PATH" +node --version # must print v24 or newer # Python 3.12+ and uv (Modal CLI is installed via uv sync below) brew install python@3.12 uv @@ -84,13 +150,16 @@ npm run build -w @open-inspect/shared # Install Python dependencies for Modal deployment (includes sandbox-runtime) cd packages/modal-infra && uv sync --frozen && cd - + +# Create your Terraform config files (edited in Step 5; never commit them) +cp terraform/environments/production/terraform.tfvars.example terraform/environments/production/terraform.tfvars +cp terraform/environments/production/backend.tfvars.example terraform/environments/production/backend.tfvars ``` --- -> **Tip**: Before proceeding, copy `terraform/environments/production/terraform.tfvars.example` to -> `terraform.tfvars` and keep it open. As you collect credentials in the following steps, paste them -> directly into this file. +> **Tip**: Keep `terraform/environments/production/terraform.tfvars` open. As you collect +> credentials in the following steps, paste them directly into this file. --- @@ -149,7 +218,10 @@ Create an R2 API Token: ### Modal -> Only required when `sandbox_provider = "modal"`. +> Only required when `sandbox_provider = "modal"` (the default, used by the core path) or +> `"modal-vm"`. Select `modal-vm` for Docker-capable VMs; see [Modal VM setup](MODAL_DOCKER.md). To +> use Daytona, Vercel Sandboxes, E2B, or Boat instead, skip this section and follow +> [Alternative Sandbox Providers](#alternative-sandbox-providers-optional). 1. Go to [Modal Settings](https://modal.com/settings) 2. **Create a new API token**: Settings -> API Tokens -> New Token @@ -160,114 +232,57 @@ Create an R2 API Token: lowercase suffix made of letters, digits, and dashes. Leave it empty for the environment whose endpoints use `https://--...modal.run`. -### Daytona - -> Only required when `sandbox_provider = "daytona"`. - -1. Create a [Daytona](https://app.daytona.io) account and generate an **API key** with the following - permissions: - - **Sandboxes**: Read, Write (runtime sandbox management and preview URLs) - - **Snapshots**: Read, Write, Delete (automated snapshot builds via Terraform) -2. Note the **API URL** (e.g., `https://app.daytona.io/api`) and optional **target** -3. Terraform builds and verifies a new base snapshot before switching the Worker to it. See the - [sandbox image workflow](../packages/sandbox-images/README.md) for dependency updates and manual - builds. -4. Set `sandbox_provider = "daytona"` in `terraform.tfvars` -5. Set `daytona_api_url`, `daytona_api_key`, and `daytona_base_snapshot` in `terraform.tfvars` - -The control plane calls the Daytona REST API directly — no shim service to deploy. - -> **Important**: the Daytona provider has no fleet-wide key of its own. Add the key for the models -> you plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets -> after deploying. See [Secrets Management](SECRETS.md) for details. - -### Vercel Sandboxes - -> Only required when `sandbox_provider = "vercel"`. - -1. Create a [Vercel API token](https://vercel.com/account/tokens) that can access your sandbox - project. -2. Note the **Project ID** for the project that will own sandbox sessions. -3. Note the **Team/Account ID** if you use a Vercel team. Leave it unset for personal accounts where - the token can access the project directly. -4. Set `sandbox_provider = "vercel"` in `terraform.tfvars`. -5. Set `vercel_sandbox_token`, `vercel_sandbox_project_id`, and optionally `vercel_sandbox_team_id` - in `terraform.tfvars`. - -The control plane calls the Vercel Sandbox API directly from Cloudflare Workers. No Modal-style shim -service is deployed. Vercel supports filesystem snapshots and prebuilt images; if you have a -reusable base snapshot, set `vercel_base_snapshot_id` to use it instead of Terraform's managed base -snapshot build. - -When Terraform runs with `sandbox_provider = "vercel"`, it builds a managed immutable Vercel -base-runtime snapshot from the checked-out sandbox runtime and Vercel bootstrap source, then passes -a deterministic snapshot name into the Worker deployment. The control plane resolves that name to -the latest created Vercel snapshot at sandbox creation time. The `vercel_base_snapshot_id` setting -is still available as a manual override. See [Vercel Sandbox Provider](VERCEL_SANDBOX_PROVIDER.md) -for the full runtime, snapshot, and resource configuration model. - -> **Important**: the Vercel provider has no fleet-wide key of its own. Add the key for the models -> you plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets -> after deploying. See [Secrets Management](SECRETS.md) for details. - -### OpenComputer - -> Only required when `sandbox_provider = "opencomputer"`. - -1. Create an OpenComputer API key. -2. Set `sandbox_provider = "opencomputer"` in `terraform.tfvars`. -3. Set `opencomputer_api_url` and `opencomputer_api_key`. -4. Leave `opencomputer_template = ""` to let Terraform build the OpenInspect runtime template, or - set it to an existing OpenComputer template name. -5. Run `terraform apply`. - -For the full template build and runtime details, see -[OpenComputer Sandbox Provider](OPENCOMPUTER_PROVIDER.md). - -### E2B - -> Only required when `sandbox_provider = "e2b"`. - -E2B needs a single credential — the **API key** (`e2b_api_key`). The control plane uses it at -runtime for the E2B REST API, and the `e2b-infra` module uses it to build the sandbox template (via -the E2B Template SDK). Create it at the [E2B dashboard](https://e2b.dev) → API Keys. - -1. Set `sandbox_provider = "e2b"` in `terraform.tfvars`. -2. Set `e2b_api_key` and `e2b_template_id` (e.g. `open-inspect-sandbox`). -3. Terraform's `e2b-infra` module builds the template automatically on `terraform apply`, and - rebuilds it when `packages/e2b-infra` or `packages/sandbox-runtime` change. To build manually: - ```bash - cd packages/e2b-infra - uv sync --frozen - E2B_API_KEY=e2b_… E2B_TEMPLATE_ID=open-inspect-sandbox uv run python build-template.py - ``` - -The control plane calls the E2B REST API directly from Cloudflare Workers. Each session runs in a -single long-lived sandbox: when its TTL (`e2b_sandbox_timeout_seconds`, default 7200) expires the -sandbox is **paused** rather than killed (`e2b_auto_pause`, default true), so sessions survive idle -gaps; the next prompt resumes it through the control plane. On the **Hobby** tier (~1h runtime cap) -lower `e2b_sandbox_timeout_seconds` to 3300. Set `e2b_auto_pause = false` to kill on timeout -instead. - -For the full runtime, lifecycle, and configuration model, see -[E2B Sandbox Provider](E2B_SANDBOX_PROVIDER.md). - -> **Important**: the E2B provider has no fleet-wide key of its own. Add the key for the models you -> plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets after -> deploying. See [Secrets Management](SECRETS.md) for details. - -### Anthropic +### Anthropic (Optional) -Required by the default configuration: the Slack bot is enabled by default and its classifier runs -on Claude, so `terraform apply` fails without this key. You can skip it only if you set both -`enable_slack_bot = false` and `enable_linear_bot = false`, or point `classification_model` at an -OpenAI model. Coding sessions themselves need no key here — those model credentials can be added as -secrets in the web app after deploying. +Optional for the core path, which sets `enable_slack_bot = false` and `enable_linear_bot = false` in +Step 5. Terraform's own default enables the Slack bot, and its classifier runs on Claude, so +`terraform apply` fails without this key unless both bots are disabled or `classification_model` +points at an OpenAI or Bedrock model. If you enable Slack or Linear later, set either +`classification_anthropic_api_key` or `anthropic_api_key` then; prefer the classifier-only key +unless you also want a deployment-wide sandbox key, because it is never injected into sandboxes. +Coding sessions themselves need no key here — those model credentials can be added as secrets in the +web app after deploying. With Modal, a key set in `anthropic_api_key` is also injected into session +sandboxes as a deployment-wide default. 1. Go to [Anthropic Console](https://console.anthropic.com) 2. Create an API key 3. Note the **API Key** (starts with `sk-ant-`) +#### Classify on Amazon Bedrock (Optional) + +The Slack and Linear classifiers can call Claude in Amazon Bedrock instead of the Claude API. This +setting affects only the classifiers, not coding sessions. The classifiers send Messages API +requests to the Amazon Bedrock runtime endpoint, `bedrock-runtime..amazonaws.com`, in the +Region you choose. + +1. Choose any AWS Region where the Claude model is available in Amazon Bedrock. Open the model's + card in the Amazon Bedrock console and copy its inference profile ID, such as + `us.anthropic.claude-haiku-4-5-20251001-v1:0`. +2. Enable the model for the AWS account once. With an identity that has `aws-marketplace:Subscribe`, + `aws-marketplace:Unsubscribe`, and `aws-marketplace:ViewSubscriptions`, open the model in the + Amazon Bedrock console model catalog and submit Anthropic's first-time use form. Until AWS + finishes setting up access, requests return `AccessDeniedException`, and every classification + falls back to asking the user. +3. In the AWS console, create a long-term Amazon Bedrock API key. Its IAM identity needs + `bedrock:InvokeModel` and `bedrock:GetInferenceProfile` on the inference profile, + `bedrock:InvokeModel` on the models it routes to, and `bedrock:CallWithBearerToken`. +4. Record the key's expiry date. AWS recommends long-term keys for exploration only, and each key + expires on the date set when you create it. Rotate `classification_bedrock_api_key` before that + date. Do not use a short-term key. It expires within 12 hours, and after that every + classification falls back to asking the user. +5. Set the model and key in `terraform.tfvars`. The model ID is `bedrock//` followed by + the inference profile ID: + + ```hcl + classification_model = "bedrock/us-east-1/us.anthropic.claude-haiku-4-5-20251001-v1:0" + classification_bedrock_api_key = "" + ``` + + The classifiers then need no Anthropic key. Terraform checks only the ID's format. Use the + inference profile ID. Bedrock rejects the plain ID `anthropic.claude-haiku-4-5` as an invalid + identifier, and rejects on-demand requests to some in-Region model IDs, such as + `anthropic.claude-haiku-4-5-20251001-v1:0`. + > **Want to use your OpenAI ChatGPT subscription?** See [Using OpenAI Models](OPENAI_MODELS.md) for > setup instructions (can be configured after deployment). > @@ -287,8 +302,8 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si 3. Fill in the basics: - **Name**: `Open-Inspect-YourName` (must be globally unique) - **Homepage URL**: Your web app URL (see below) - - **Webhook**: Leave "Active" unchecked for now. Step 7c enables it when - `enable_github_bot = true` for GitHub automations or bot commands. + - **Webhook**: Leave "Active" unchecked for now. [GitHub Bot (Optional)](#github-bot-optional) + enables it when `enable_github_bot = true` for GitHub automations or bot commands. 4. If enabling GitHub sign-in, configure **Identifying and authorizing users** (OAuth): - **Callback URL**: `{your-web-app-url}/api/auth/callback/github` @@ -311,20 +326,19 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si > **bot** identity. 5. Set **Repository permissions**: - - Actions: **Read-only** _(required for GitHub workflow-run automations)_ - - Checks: **Read-only** _(required for GitHub check-suite automations)_ - Contents: **Read & Write** - - Issues: **Read & Write** _(required if enabling GitHub bot)_ - Pull requests: **Read & Write** _(also authorizes creating and applying labels to session-created pull requests)_ - Metadata: **Read-only** + - If enabling the GitHub bot, also grant Actions: **Read-only** _(workflow-run automations)_, + Checks: **Read-only** _(check-suite automations)_, and Issues: **Read & Write**. 6. If using `ALLOWED_GITHUB_ORGS`/`allowed_github_orgs`, set **Organization permissions**: - Members: **Read-only** - For existing GitHub Apps, republish the permission change and request/approve installation updates before testing org membership sign-in. -7. If GitHub sign-in uses `allowed_emails` or `allowed_email_domains`, set **Account permissions**: - - Email addresses: **Read-only** _(without it the app cannot read verified emails and those - allowlists deny every GitHub sign-in)_ +7. If enabling GitHub sign-in, set **Account permissions**: + - Email addresses: **Read-only** _(every GitHub sign-in requires a verified email, including + username-only, org-only, and intentionally open deployments)_ - For existing GitHub Apps, republish the permission change and request/approve installation updates, otherwise the added permission does not apply to current installs. 8. Click **"Create GitHub App"** @@ -357,103 +371,15 @@ For GitHub sign-in, you should also have: - **Client ID** (e.g., `Iv1.abc123...`) - **Client Secret** (e.g., `abc123...`) ---- - -## Step 4: Create Slack App (Optional) - -Skip this step if you don't need Slack integration. - -### Create the App - -1. Go to [Slack API Apps](https://api.slack.com/apps) -2. Click **"Create New App"** → **"From scratch"** -3. Name it (e.g., `Open-Inspect`) and select your workspace - -After deploying the Slack worker, you can configure the app from -[`packages/slack-bot/slack-app-manifest.yaml`](../packages/slack-bot/slack-app-manifest.yaml) -instead of entering the settings below individually. Replace `SLACK_EVENTS_URL` with the worker's -`/events` URL and `SLACK_INTERACTIONS_URL` with its `/interactions` URL before applying the -manifest. The template includes `message.channels` and `message.groups` for channel-message -automations; remove those subscriptions if the deployment will not use that feature. - -Before `terraform apply`, configure the OAuth scopes below, install the app, and collect its bot -token and signing secret. Apply the URL-dependent manifest after deployment, when Slack can verify -the worker's `/events` and `/interactions` endpoints. - -### Configure OAuth & Permissions - -1. Go to **OAuth & Permissions** in the sidebar -2. Add **Bot Token Scopes**: - - `assistant:write` - - `app_mentions:read` - - `chat:write` - - `channels:history` - - `channels:read` - - `groups:history` - - `groups:read` - - `im:history` - - `files:read` (lets the bot read images attached to messages and forward them to sessions) - - `files:write` - - `reactions:write` - - `users:read` - - `users:read.email` -3. Click **"Install to Workspace"** -4. Note the **Bot Token** (`xoxb-...`) - -> **Important**: If you update bot token scopes later, you must **reinstall the app** to your -> workspace for the new permissions to take effect. - -### Upgrade an Existing Slack Deployment - -Queued delivery applies to every Slack completion, including text-only replies. Before the first -`terraform apply` after upgrading: - -1. Add **Account | Queues | Edit** to the Cloudflare API token used by Terraform. Terraform needs - this permission to create the completion queue, dead-letter queue, Worker binding, and consumer. -2. Ensure the Slack app has `assistant:write`, `users:read`, `users:read.email`, `files:read`, and - `files:write`. Reinstall the app once for the workspace, and update `slack_bot_token` if Slack - issued a replacement. These scopes enable Agent view, user identity resolution, inbound images, - and generated-media delivery. -3. Run `terraform apply`, then verify a text completion, an inbound image attached to a prompt, and - a generated-media attachment. If the token lacks Queue access, the apply fails while provisioning - the new resources; grant the permission and rerun the apply. - -No individual Slack user needs to reauthorize the app. Deployments with `enable_slack_bot = false` -still create the image-build finalization Queue and dead-letter Queue. - -### Get Signing Secret - -1. Go to **Basic Information** -2. Note the **Signing Secret** - -### Event Subscriptions (Configure After Deployment) - -Event Subscriptions require the Slack bot worker to be deployed first for URL verification. You'll -configure this in **Step 7b** after running Terraform. - ---- - -## Step 4b: Create a Linear OAuth App (Optional) - -Skip this step if you don't need the Linear Agent integration. - -1. Create an application in **Linear Settings → API → Applications**. -2. Enable webhooks and subscribe to **Agent session events**. **Permission changes** and **Inbox - notifications** are also useful operational signals. -3. Enable **Client credentials tokens**. This Linear-side setting is not managed by Terraform. -4. Configure these URLs, replacing the deployment name and Workers subdomain: - - Callback URL: - `https://open-inspect-linear-bot-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/oauth/callback` - - Webhook URL: - `https://open-inspect-linear-bot-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/webhook` -5. Record the client ID, client secret, and webhook signing secret for `terraform.tfvars`. - -The app is installed after deployment in **Step 7d**. Runtime access uses replaceable -client-credentials tokens; authorization-code refresh tokens are not stored as runtime credentials. +The App private key belongs in the control plane, not in a Modal `github-app` secret or a session +secret. Fresh and restored sandboxes obtain scoped Git credentials from the control plane on demand. +The optional GitHub bot Worker still needs its own App credential bindings; Terraform supplies them +when enabled. Modal's `llm-api-keys` and `internal-api` secrets remain required and are provisioned +by Terraform. --- -## Step 5: Generate Security Secrets +## Step 4: Generate Security Secrets Generate these random secrets (you'll need them for `terraform.tfvars`): @@ -478,15 +404,10 @@ Save these values somewhere secure—you'll need them in the next step. --- -## Step 6: Configure Terraform +## Step 5: Configure Terraform -```bash -cd terraform/environments/production - -# Copy the example files -cp terraform.tfvars.example terraform.tfvars -cp backend.tfvars.example backend.tfvars -``` +Edit the `backend.tfvars` and `terraform.tfvars` files you created in Step 1, in +`terraform/environments/production`. ### Configure `backend.tfvars` @@ -527,13 +448,14 @@ modal_workspace = "your-modal-workspace" modal_environment = "your-modal-environment" modal_environment_web_suffix = "your-modal-web-suffix" # Lowercase letters, digits, dashes; empty for https://workspace--... endpoints -# Sandbox provider: "modal" (default), "daytona", or "vercel" +# Sandbox provider: "modal" (default), "modal-vm", "daytona", "vercel", "e2b", or "boat" # sandbox_provider = "modal" # Daytona (only required when sandbox_provider = "daytona") # daytona_api_url = "https://app.daytona.io/api" # daytona_api_key = "your-daytona-api-key" # daytona_base_snapshot = "your-snapshot-name" +# daytona_base_snapshot_memory_gib = 2 # Vercel Sandboxes (only required when sandbox_provider = "vercel") # vercel_sandbox_token = "your-vercel-token" @@ -547,6 +469,10 @@ modal_environment_web_suffix = "your-modal-web-suffix" # Lowercase letters, digi # e2b_api_key = "your-e2b-api-key" # runtime REST API key (also auths the build) # e2b_template_id = "open-inspect-sandbox" +# Boat (only required when sandbox_provider = "boat") +# boat_api_key = "boat_..." # runtime REST API key (also auths the build) +# boat_base_snapshot = "open-inspect" # prefix of the Terraform-managed saved snapshot + # GitHub App repository access (required in every deployment) github_app_id = "123456" github_app_installation_id = "12345678" @@ -564,33 +490,39 @@ github_client_secret = "your-client-secret" # Generated in GitHub App settings google_client_id = "" google_client_secret = "" -# Slack (set enable_slack_bot = false to disable Slack integration) +# Slack. Terraform defaults enable_slack_bot to true; keep false for the core path +# (see "Slack Bot (Optional)" to enable it later). enable_slack_bot = false slack_bot_token = "" slack_signing_secret = "" # GitHub Bot (set enable_github_bot = true to deploy the webhook worker) enable_github_bot = false -github_webhook_secret = "" # From Step 5 (required if enabled) +github_webhook_secret = "" # From Step 4 (required if enabled) github_bot_username = "" # e.g., "my-app[bot]" (your GitHub App's bot login) # Linear Agent (set enable_linear_bot = true to deploy the webhook worker) enable_linear_bot = false -linear_client_id = "" # From Step 4b (required if enabled) -linear_client_secret = "" # From Step 4b (required if enabled) -linear_webhook_secret = "" # From Step 4b (required if enabled) +linear_client_id = "" # From the Linear app (required if enabled) +linear_client_secret = "" # From the Linear app (required if enabled) +linear_webhook_secret = "" # From the Linear app (required if enabled) # API Keys. Optional: leave blank to add model credentials as secrets in the web # app instead. Required only when the Slack/Linear classifier runs on Anthropic. -anthropic_api_key = "sk-ant-..." +anthropic_api_key = "" +# classification_anthropic_api_key = "" # Classifier-only key; never reaches sandboxes # Slack/Linear classifier provider, chosen by classification_model. -# An OpenAI model requires classification_openai_api_key. An Anthropic model -# needs no new value — it is served by anthropic_api_key above. +# An OpenAI model requires classification_openai_api_key. A Bedrock model +# ("bedrock//") requires classification_bedrock_api_key. +# An Anthropic model is served by classification_anthropic_api_key, falling back +# to anthropic_api_key. # classification_model = "claude-haiku-4-5" # e.g. "gpt-5.4-mini" to classify on OpenAI classification_openai_api_key = "" # Required when classification_model is an OpenAI id +# classification_bedrock_api_key = "" # Required when classification_model is a Bedrock id +# classification_reasoning_effort = "low" # OpenAI ids only; blank keeps the model default -# Security Secrets (from Step 5) +# Security Secrets (from Step 4) token_encryption_key = "your-generated-value" repo_secrets_encryption_key = "your-generated-value" # provider_accounts_encryption_key = "existing-key" # Optional override; Terraform generates one @@ -612,7 +544,7 @@ project_root = "../../../" # Leave empty to keep the built-in favicon and default in-app icon. # app_icon_url = "" -# Initial deployment: set both to false (see Step 7) +# Initial deployment: set both to false (see Step 6) enable_durable_object_bindings = false enable_service_bindings = false @@ -626,8 +558,20 @@ allowed_github_orgs = "" # Comma-separated orgs whose act # Explicitly opt into open access only if you want any authenticated user to be # able to sign in when all allowlists are empty. unsafe_allow_all_users = false + +# Fresh deployment opt-in: sets control-plane TEAMS_ENFORCEMENT=on. +# The Terraform/runtime default remains shadow. Existing deployments must review +# production shadow_denied audit entries before opting in. +teams_enforcement = "on" ``` +> **Core path bot settings**: The snippet above deploys with `enable_slack_bot = false`, +> `enable_github_bot = false`, and `enable_linear_bot = false`. Keep `enable_slack_bot = false` +> explicit: Terraform defaults it to `true`, which requires Slack credentials and an Anthropic (or +> OpenAI or Bedrock classifier) key. With all three bots off, `anthropic_api_key` is optional. Turn +> bots on later with the [Slack](#slack-bot-optional), [Linear](#linear-agent-optional), and +> [GitHub bot](#github-bot-optional) sections. + ### Choose Sign-In Providers Complete credential pairs are the enablement policy. Terraform rejects partial pairs and rejects a @@ -643,6 +587,9 @@ The GitHub App ID, PKCS#8 private key, and installation ID remain required in al configurations because they authorize repository operations; they do not enable GitHub sign-in. The `/login` page reads the enabled provider set from the control plane on every request. +The core path uses GitHub sign-in. To add or switch to Google, see +[Enable Google Login (Optional)](#enable-google-login-optional). + > **Note**: Review `allowed_users`, `allowed_email_domains`, `allowed_emails`, and > `allowed_github_orgs` carefully — these control who can sign in. Terraform fails if all are empty > unless you explicitly set `unsafe_allow_all_users = true`. **Allowlists use OR semantics**: @@ -653,31 +600,9 @@ configurations because they authorize repository operations; they do not enable > until session expiry. The `read:org` OAuth scope is requested only when org access is configured, > and GitHub Apps using org access need Organization permissions: Members read-only. -### Enable Google Login (Optional) - -Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git -operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal -GitHub attribution unless the same verified email is also a linked GitHub identity). - -1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an - **OAuth client ID** of type **Web application**. -2. Add the authorized redirect URI `{your-web-app-url}/api/auth/callback/google` (e.g. - `https://open-inspect-yourname.vercel.app/api/auth/callback/google`). It must match the deployed - URL exactly. -3. On the OAuth consent screen, request only the `openid`, `email`, and `profile` scopes — these are - non-sensitive, so Google requires no app-verification review. -4. Set `google_client_id` and `google_client_secret` (both required together), and add at least one - allowed user to `allowed_emails` (exact addresses) or `allowed_email_domains`. Leave the GitHub - client pair empty for Google-only sign-in, or keep it configured to offer both providers. The - next request to `/login` reflects the deployed pair without a web flag or rebuild. - -> **Security note**: Google sign-in is admitted only for **verified** emails that match an -> allowlist. Because addresses on shared domains like `gmail.com` are generic, prefer -> `allowed_emails` (exact match) over `allowed_email_domains` for those users. - --- -## Step 7: Deploy with Terraform +## Step 6: Deploy with Terraform Deployment requires **two phases** due to Cloudflare's Durable Object and service binding requirements. @@ -695,7 +620,7 @@ enable_service_bindings = false ```bash # From the repository root -npm run build -w @open-inspect/control-plane -w @open-inspect/slack-bot -w @open-inspect/github-bot +npm run build -w @open-inspect/control-plane -w @open-inspect/slack-bot -w @open-inspect/github-bot -w @open-inspect/linear-bot ``` Then run: @@ -719,73 +644,449 @@ enable_durable_object_bindings = true enable_service_bindings = true ``` -Then run: +Then run: + +```bash +terraform apply +``` + +Terraform will update the workers with the required bindings. + +--- + +## Step 7: Deploy the Web App + +### If using Cloudflare (`web_platform = "cloudflare"`) + +Terraform handles the full build and deploy automatically — the web app is built with OpenNext and +deployed as a Cloudflare Worker during `terraform apply`. No manual step needed. + +To serve the web app on your own hostname, see +[Custom Domain for the Web App (Optional)](#custom-domain-for-the-web-app-optional). + +### If using Vercel (`web_platform = "vercel"`) + +Terraform creates the Vercel project and configures environment variables, but does **not** deploy +the code. You have two options: + +#### Option A: Deploy via CLI (Recommended for First Deploy) + +```bash +# From the repository root (replace {deployment_name} with your value from terraform.tfvars) +npx vercel link --project open-inspect-{deployment_name} +npx vercel --prod +``` + +> **Note**: The Vercel project is configured with custom build commands for the monorepo structure. +> Terraform sets these automatically: +> +> - Install: `cd ../.. && npm install && npm run build -w @open-inspect/shared` +> - Build: `npm run build` (also generates the isolated Mermaid renderer) + +#### Option B: Link Git Repository (For Automatic Deployments) + +1. Go to [Vercel Dashboard](https://vercel.com/dashboard) +2. Find the `open-inspect-{deployment_name}` project +3. Go to **Settings → Git** +4. Click **"Connect Git Repository"** and select your fork +5. Vercel will automatically deploy on push to main + +> **Note**: If you link Git, ensure the build settings match those configured by Terraform (Settings +> → General → Build & Development Settings). + +--- + +## Step 8: Verify Deployment + +After deployment completes, verify each component from the repository root: + +```bash +# Get the verification commands from Terraform +terraform -chdir=terraform/environments/production output verification_commands +``` + +Or manually: + +```bash +# 1. Control Plane health check (replace {deployment_name} and YOUR-SUBDOMAIN) +curl https://open-inspect-control-plane-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/health + +# 2. Sandbox backend health check +# Modal exposes a health endpoint. Prefer the exact URL from terraform output verification_commands. +# Manual form: https://[-]--open-inspect-api-health.modal.run +MODAL_WORKSPACE_SLUG="YOUR-WORKSPACE" # or "YOUR-WORKSPACE-YOUR-MODAL-WEB-SUFFIX" +curl https://${MODAL_WORKSPACE_SLUG}--open-inspect-api-health.modal.run +# Daytona, Vercel, and E2B use their provider APIs directly, so there is no +# Open-Inspect shim health URL. + +# 3. Web app (should return 200) +curl -I "$(terraform -chdir=terraform/environments/production output -raw web_app_url)" +``` + +Visit your web app URL and sign in with each configured provider. New users get the Member role, +which cannot manage secrets, so the end-to-end session test comes after Owner and team setup in +Steps 9 and 10. + +--- + +## Step 9: Bootstrap the Workspace Owner + +Owner assignment is an explicit operator action. After the web app is deployed and verified: + +1. Have the intended Owner sign in to the deployed web application once. This creates their + canonical user and default role assignment. +2. While signed in, open `/api/auth/get-session` on the web application origin and record the + 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID, + never an email address. +3. Obtain the D1 database name with `terraform output -raw d1_database_name` from + `terraform/environments/production`. +4. From the repository root, run the remote dry run (the default): + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" +``` + +5. If the preflight is `ready`, confirm the target and execute the same command with `--execute`: + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" \ + --execute +``` + +If the preflight is `no-op`, the target is already the current unsuspended Owner; skip execution. If +it is `refused`, stop and resolve the reported reason before retrying. + +The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or +`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous +assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler +`--command` batch: it writes one redacted `workspace.owner_bootstrapped` service audit event, +replaces the target's assignment, and returns the exact audit-bound postcondition from that same +transaction. A no-op writes nothing. A lost batch response can leave the outcome uncertain; the +command does not automatically retry writes or claim success without the postcondition. + +A successful execution prints the postcondition row as JSON with `"status":"executed"`. + +6. Rerun the dry run from step 4 and expect `no-op` for the selected unsuspended Owner: + +```bash +npm run rbac:bootstrap-owner -- \ + --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ + --user "" +``` + +The preflight row should report `"status":"no-op"` with the detail +`selected user is already the current unsuspended Owner`. If it reports `refused` instead, the +command exits non-zero and the `detail` field gives the reason. The control-plane `/health` endpoint +reports service liveness, not Owner status. + +## Step 10: Create the First Team and Test a Session + +1. As the bootstrapped Owner, open **Settings > Teams > Create team**, enter a name and slug, and + create your first team. The creator becomes its first **Lead** automatically; Owner bootstrap + alone does not create a team or membership. +2. Open the team's settings. Choose **Join policy**: **Invite only** (the creation default) or + **Open** for workspace users to join themselves. Choose **Default visibility** for new sessions: + **Team**, **Workspace**, or **Private**. Use Team for team-readable work, and confirm the + deployed control plane has `TEAMS_ENFORCEMENT=on` before relying on that read boundary. +3. In **Teams > your team > Repositories**, grant the repositories the team needs. New teams have no + grants. Prefer named repositories; use **All installation repositories** only when that broad + access is intended. Include every repository required by a multi-repository environment. +4. Have the other operators sign in once, then add them in the team's member list. For an open team, + they can instead use **Join team** from Teams. Assign Lead to operators who need team-management + duties. Workspace Owner/Administrator status is not a substitute for joining the team to perform + non-read actions on its sessions. +5. Add a model credential in the team's **Secrets** tab, or use **Settings > Secrets** for a global + or repository key (e.g. `ANTHROPIC_API_KEY` for Claude). Skip this if a suitable deployment-wide + credential is already configured. See [Secrets Management](SECRETS.md). +6. Create a session with this team selected, a granted repository, the intended visibility, and a + model whose credential is configured. Send a prompt and verify sandbox startup, repository + checkout, and streamed results. Have another team member verify the intended collaboration. +7. If new sessions, environments, and automation definitions must be team-owned, enable **Require a + team for new sessions** in **Settings > Teams** after membership and grants are ready. This + separate `requireTeamOnCreate` policy also covers new environments and automation definitions; it + does not migrate existing workspace resources. + +--- + +## Optional Sections + +Your core deployment is complete. Everything below is optional; follow only the sections you need. + +- [Alternative Sandbox Providers](#alternative-sandbox-providers-optional): Daytona, Vercel + Sandboxes, E2B, or Boat instead of Modal +- [Enable Google Login](#enable-google-login-optional) +- [Slack Bot](#slack-bot-optional) +- [Linear Agent](#linear-agent-optional) +- [GitHub Bot](#github-bot-optional) +- [Custom Domain for the Web App](#custom-domain-for-the-web-app-optional) +- [Set Up CI/CD](#set-up-cicd-optional) +- [Customizing the App Name and Icon](#customizing-the-app-name-and-icon-optional) +- [Updating Your Deployment](#updating-your-deployment) +- [Troubleshooting](#troubleshooting) + +--- + +## Alternative Sandbox Providers (Optional) + +The core path uses Modal, the default `sandbox_provider`. To run sessions on another provider, set +`sandbox_provider` in `terraform.tfvars` and follow the matching section below instead of the +[Modal](#modal) credentials in Step 2. Terraform accepts `modal`, `modal-vm`, `daytona`, `vercel`, +`e2b`, or `boat`. The `modal-vm` backend uses the same Modal credentials; see +[Modal VM backend](MODAL_DOCKER.md). + +### Daytona + +> Only required when `sandbox_provider = "daytona"`. + +1. Create a [Daytona](https://app.daytona.io) account and generate an **API key** with the following + permissions: + - **Sandboxes**: Read, Write (runtime sandbox management and preview URLs) + - **Snapshots**: Read, Write, Delete (automated snapshot builds via Terraform) +2. Note the **API URL** (e.g., `https://app.daytona.io/api`) and optional **target** +3. Terraform builds and verifies a new base snapshot before switching the Worker to it. See the + [sandbox image workflow](../packages/sandbox-images/README.md) for dependency updates and manual + builds. +4. Set `sandbox_provider = "daytona"` in `terraform.tfvars` +5. Set `daytona_api_url`, `daytona_api_key`, and `daytona_base_snapshot` in `terraform.tfvars`. + `daytona_base_snapshot_memory_gib` controls the memory inherited by sandboxes created from the + snapshot and defaults to `2`. + +The control plane calls the Daytona REST API directly — no shim service to deploy. + +Two optional settings: + +- `daytona_toolbox_api_url` overrides the per-sandbox toolbox proxy. Leave it empty on a deployment + whose sandboxes report their own. +- `daytona_prebuilds_enabled` admits new Daytona prebuilt-image builds and lets fresh sessions boot + from one. It defaults to `false`; see [Daytona prebuilds](IMAGE_PREBUILD.md#daytona-prebuilds) for + the gates an operator should clear against their own organization and target before turning it on. + +> **Important**: the Daytona provider has no fleet-wide key of its own. Add the key for the models +> you plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets +> after deploying. See [Secrets Management](SECRETS.md) for details. + +### Vercel Sandboxes + +> Only required when `sandbox_provider = "vercel"`. + +1. Create a [Vercel API token](https://vercel.com/account/tokens) that can access your sandbox + project. +2. Note the **Project ID** for the project that will own sandbox sessions. +3. Note the **Team/Account ID** if you use a Vercel team. Leave it unset for personal accounts where + the token can access the project directly. +4. Set `sandbox_provider = "vercel"` in `terraform.tfvars`. +5. Set `vercel_sandbox_token`, `vercel_sandbox_project_id`, and optionally `vercel_sandbox_team_id` + in `terraform.tfvars`. + +The control plane calls the Vercel Sandbox API directly from Cloudflare Workers. No Modal-style shim +service is deployed. Vercel supports filesystem snapshots and prebuilt images; if you have a +reusable base snapshot, set `vercel_base_snapshot_id` to use it instead of Terraform's managed base +snapshot build. + +When Terraform runs with `sandbox_provider = "vercel"`, it builds a managed immutable Vercel +base-runtime snapshot from the checked-out sandbox runtime and Vercel bootstrap source, then passes +a deterministic snapshot name into the Worker deployment. The control plane resolves that name to +the latest created Vercel snapshot at sandbox creation time. The `vercel_base_snapshot_id` setting +is still available as a manual override. See [Vercel Sandbox Provider](VERCEL_SANDBOX_PROVIDER.md) +for the full runtime, snapshot, and resource configuration model. + +> **Important**: the Vercel provider has no fleet-wide key of its own. Add the key for the models +> you plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets +> after deploying. See [Secrets Management](SECRETS.md) for details. + +### E2B + +> Only required when `sandbox_provider = "e2b"`. + +E2B needs a single credential — the **API key** (`e2b_api_key`). The control plane uses it at +runtime for the E2B REST API, and the `e2b-infra` module uses it to build the sandbox template (via +the E2B Template SDK). Create it at the [E2B dashboard](https://e2b.dev) → API Keys. + +1. Set `sandbox_provider = "e2b"` in `terraform.tfvars`. +2. Set `e2b_api_key` and `e2b_template_id` (e.g. `open-inspect-sandbox`). +3. Terraform's `e2b-infra` module builds the template automatically on `terraform apply`, and + rebuilds it when `packages/e2b-infra` or `packages/sandbox-runtime` change. To build manually: + ```bash + cd packages/e2b-infra + uv sync --frozen + E2B_API_KEY=e2b_… E2B_TEMPLATE_ID=open-inspect-sandbox uv run python build-template.py + ``` + +The control plane calls the E2B REST API directly from Cloudflare Workers. Each session runs in a +single long-lived sandbox: when its TTL (`e2b_sandbox_timeout_seconds`, default 7200) expires the +sandbox is **paused** rather than killed (`e2b_auto_pause`, default true), so sessions survive idle +gaps; the next prompt resumes it through the control plane. On the **Hobby** tier (~1h runtime cap) +lower `e2b_sandbox_timeout_seconds` to 3300. Set `e2b_auto_pause = false` to kill on timeout +instead. + +For the full runtime, lifecycle, and configuration model, see +[E2B Sandbox Provider](E2B_SANDBOX_PROVIDER.md). + +> **Important**: the E2B provider has no fleet-wide key of its own. Add the key for the models you +> plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets after +> deploying. See [Secrets Management](SECRETS.md) for details. + +### Boat + +> Only required when `sandbox_provider = "boat"`. + +Boat needs a single credential — the **API key** (`boat_api_key`). The control plane uses it at +runtime for the Boat REST API, and the `boat-infra` module uses it to build the base snapshot. +Create it in the [Boat dashboard](https://boat.dev) under API keys. + +1. Set `sandbox_provider = "boat"` in `terraform.tfvars`. +2. Set `boat_api_key` and `boat_base_snapshot` (a name prefix such as `open-inspect`). +3. Terraform's `boat-infra` module builds the saved snapshot automatically on `terraform apply`, and + builds a new one when `packages/boat-infra`, `packages/sandbox-images` or + `packages/sandbox-runtime` change. To build manually: + ```bash + cd packages/boat-infra + uv sync --frozen + BOAT_API_KEY=boat_… BOAT_BASE_SNAPSHOT=open-inspect uv run python build-template.py + ``` + +The control plane calls the Boat REST API directly from Cloudflare Workers. Each session runs in a +single Boat VM: idle sessions are **stopped** (the disk is snapshotted and the machine released), +and the next prompt resumes the same sandbox on a new machine with its files intact. Boat keeps a +stopped sandbox's disk for 14 days. Sessions pick the smallest Boat machine size that satisfies +their CPU and memory settings, or `boat_sandbox_type` (default `default`) when they set none. + +For the full runtime, lifecycle, and configuration model, see +[Boat Sandbox Provider](BOAT_SANDBOX_PROVIDER.md). + +> **Important**: the Boat provider has no fleet-wide key of its own. Add the key for the models you +> plan to use — `ANTHROPIC_API_KEY` for Claude — as a **global secret** in Settings > Secrets after +> deploying. See [Secrets Management](SECRETS.md) for details. + +--- + +## Enable Google Login (Optional) + +Google login lets non-developer users (PMs, support agents) sign in without a GitHub account. Git +operations still use the shared GitHub App, and their PRs fall back to the App bot (no personal +GitHub attribution unless the same verified email is also a linked GitHub identity). + +1. In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an + **OAuth client ID** of type **Web application**. +2. Add the authorized redirect URI `{your-web-app-url}/api/auth/callback/google` (e.g. + `https://open-inspect-yourname.vercel.app/api/auth/callback/google`). It must match the deployed + URL exactly. +3. On the OAuth consent screen, request only the `openid`, `email`, and `profile` scopes — these are + non-sensitive, so Google requires no app-verification review. +4. Set `google_client_id` and `google_client_secret` (both required together). For restricted + access, admit Google users through `allowed_emails` (exact addresses) or `allowed_email_domains`; + for intentionally open access with no allowlists, set `unsafe_allow_all_users = true`. Leave the + GitHub client pair empty for Google-only sign-in, or keep it configured to offer both providers. + The next request to `/login` reflects the deployed pair without a web flag or rebuild. + +> **Security note**: Under restricted access, Google sign-in is admitted only for **verified** +> emails that match an allowlist. Because addresses on shared domains like `gmail.com` are generic, +> prefer `allowed_emails` (exact match) over `allowed_email_domains` for those users. + +--- + +## Slack Bot (Optional) + +Skip this section if you don't need Slack integration. + +### Create the Slack App + +1. Go to [Slack API Apps](https://api.slack.com/apps) +2. Click **"Create New App"** → **"From scratch"** +3. Name it (e.g., `Open-Inspect`) and select your workspace + +After deploying the Slack worker, you can configure the app from +[`packages/slack-bot/slack-app-manifest.yaml`](../packages/slack-bot/slack-app-manifest.yaml) +instead of entering the settings below individually. Replace `SLACK_EVENTS_URL` with the worker's +`/events` URL and `SLACK_INTERACTIONS_URL` with its `/interactions` URL before applying the +manifest. The template includes `message.channels` and `message.groups` for channel-message +automations; remove those subscriptions if the deployment will not use that feature. + +Before `terraform apply`, configure the OAuth scopes below, install the app, and collect its bot +token and signing secret. Apply the URL-dependent manifest after deployment, when Slack can verify +the worker's `/events` and `/interactions` endpoints. -```bash -terraform apply -``` +#### Configure OAuth & Permissions -Terraform will update the workers with the required bindings. +1. Go to **OAuth & Permissions** in the sidebar +2. Add **Bot Token Scopes**: + - `assistant:write` + - `app_mentions:read` + - `chat:write` + - `channels:history` + - `channels:read` + - `groups:history` + - `groups:read` + - `im:history` + - `files:read` (lets the bot read images attached to messages and forward them to sessions) + - `files:write` + - `reactions:write` + - `users:read` + - `users:read.email` +3. Click **"Install to Workspace"** +4. Note the **Bot Token** (`xoxb-...`) ---- +> **Important**: If you update bot token scopes later, you must **reinstall the app** to your +> workspace for the new permissions to take effect. -## Step 7a: Bootstrap the Workspace Owner +#### Upgrade an Existing Slack Deployment -Owner assignment is an explicit operator action. After both deployment phases complete: +Queued delivery applies to every Slack completion, including text-only replies. Before the first +`terraform apply` after upgrading: -1. Have the intended Owner sign in to the deployed web application once. This creates their - canonical user and default role assignment. -2. While signed in, open `/api/auth/get-session` on the web application origin and record the - 32-character lowercase hexadecimal `user.id`. The bootstrap command accepts this canonical ID, - never an email address. -3. Obtain the D1 database name with `terraform output -raw d1_database_name` from - `terraform/environments/production`. -4. From the repository root, run the remote dry run (the default): +1. Add **Account | Queues | Edit** to the Cloudflare API token used by Terraform. Terraform needs + this permission to create the completion queue, dead-letter queue, Worker binding, and consumer. +2. Ensure the Slack app has `assistant:write`, `users:read`, `users:read.email`, `files:read`, and + `files:write`. Reinstall the app once for the workspace, and update `slack_bot_token` if Slack + issued a replacement. These scopes enable Agent view, user identity resolution, inbound images, + and generated-media delivery. +3. Run `terraform apply`, then verify a text completion, an inbound image attached to a prompt, and + a generated-media attachment. If the token lacks Queue access, the apply fails while provisioning + the new resources; grant the permission and rerun the apply. -```bash -npm run rbac:bootstrap-owner -- \ - --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ - --user "" -``` +No individual Slack user needs to reauthorize the app. Deployments with `enable_slack_bot = false` +still create the image-build finalization Queue and dead-letter Queue. -5. Confirm the preflight result is `ready` (or `no-op` when the target is already the current - unsuspended Owner), then execute the same command with `--execute`: +#### Get Signing Secret -```bash -npm run rbac:bootstrap-owner -- \ - --database "$(terraform -chdir=terraform/environments/production output -raw d1_database_name)" \ - --user "" \ - --execute -``` +1. Go to **Basic Information** +2. Note the **Signing Secret** -The command uses Wrangler credentials (`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, or -`wrangler login`) and targets remote D1. It refuses a suspended/missing user, a missing or ambiguous -assignment, or another unsuspended Owner. There is no force option. Execution is one atomic Wrangler -SQL file: it writes one redacted `workspace.owner_bootstrapped` service audit event and replaces the -target's assignment. A no-op writes nothing. +#### Enable the Slack Bot in Terraform -6. Verify the control-plane health response contains `"rbac":{"ownerAssignment":"present"}`: +Set these in `terraform.tfvars`, then run `terraform apply` from +`terraform/environments/production`: -```bash -curl "$(terraform -chdir=terraform/environments/production output -raw control_plane_url)/health" +```hcl +enable_slack_bot = true +slack_bot_token = "xoxb-..." +slack_signing_secret = "your-signing-secret" +classification_anthropic_api_key = "sk-ant-..." # Classifier-only; or set anthropic_api_key ``` -This health value reports current state: `present` means at least one Owner assignment belongs to an -unsuspended user. +The Slack classifier needs an Anthropic key unless `classification_model` points at an OpenAI or +Bedrock model; see [Anthropic (Optional)](#anthropic-optional). ---- +#### Event Subscriptions (Configure After Deployment) + +Event Subscriptions require the Slack bot worker to be deployed first for URL verification. You'll +configure this in [Complete Slack Setup](#complete-slack-setup) after running Terraform. -## Step 7b: Complete Slack Setup (If Using Slack) +### Complete Slack Setup Now that the Slack bot worker is deployed, configure the agent experience, App Home, and event subscriptions. -### Enable Agents +#### Enable Agents 1. Go to [Slack Apps](https://api.slack.com/apps) -> Your Slack App → **Agents** 2. Enable the agent feature and use `AI coding assistant for your codebase` as the agent description -### Enable App Home +#### Enable App Home The App Home provides settings for users' preferred model, reasoning effort, and branch. The writable Messages tab lets users start direct-message sessions. @@ -794,7 +1095,7 @@ writable Messages tab lets users start direct-message sessions. 2. Under **Show Tabs**, toggle **"Home Tab"** to On 3. Toggle **"Messages Tab"** to On and allow users to send messages -### Configure Event Subscriptions +#### Configure Event Subscriptions 1. Go to [Slack Apps](https://api.slack.com/apps) -> Your Slack App → **Event Subscriptions** 2. Toggle **"Enable Events"** to On @@ -813,7 +1114,7 @@ writable Messages tab lets users start direct-message sessions. - `message.im` (enables direct message support) 6. Click **Save Changes** -### Configure Interactivity +#### Configure Interactivity 1. Go to **Interactivity & Shortcuts** 2. Toggle **"Interactivity"** to On @@ -828,7 +1129,7 @@ writable Messages tab lets users start direct-message sessions. This is required for searchable Slack repository pickers that use external data sources. 5. Click **Save Changes** -### Invite the Bot to Channels +#### Invite the Bot to Channels In Slack, for each channel where you want the bot to respond: @@ -837,11 +1138,84 @@ In Slack, for each channel where you want the bot to respond: The bot only responds to @mentions in channels it has been invited to. +#### Choose Team Routing + +In Open-Inspect, bind each Slack channel in **Teams > your team > Channels**. Then open **Settings > +Integrations > Slack** and save **Unbound channels**: `workspace` (the default, **Create +workspace-level sessions**) or `reject` (**Reject requests until the channel is bound**). This is +the integration's workspace-wide `unboundChannels` setting, not an environment variable or Terraform +input. Slack DMs are personal conversations and bypass the unbound-channel rejection policy; session +creation still checks other policies, including `requireTeamOnCreate`. A workspace fallback does not +override a requirement to choose a team. See [Slack Integration](integrations/SLACK.md). + +--- + +## Linear Agent (Optional) + +Skip this section if you don't need the Linear Agent integration. + +### Create a Linear OAuth App + +1. Create an application in **Linear Settings → API → Applications**. +2. Enable webhooks and subscribe to **Agent session events**. **Permission changes** and **Inbox + notifications** are also useful operational signals. +3. Enable **Client credentials tokens**. This Linear-side setting is not managed by Terraform. +4. Configure these URLs, replacing the deployment name and Workers subdomain: + - Callback URL: + `https://open-inspect-linear-bot-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/oauth/callback` + - Webhook URL: + `https://open-inspect-linear-bot-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/webhook` +5. Record the client ID, client secret, and webhook signing secret for `terraform.tfvars`. +6. Set `enable_linear_bot = true`, `linear_client_id`, `linear_client_secret`, and + `linear_webhook_secret` in `terraform.tfvars`, then run `terraform apply`. Like Slack, the Linear + classifier needs an Anthropic key unless `classification_model` points at an OpenAI or Bedrock + model; see [Anthropic (Optional)](#anthropic-optional). + +The app is installed after deployment in [Install the Linear Agent](#install-the-linear-agent). +Runtime access uses replaceable client-credentials tokens; authorization-code refresh tokens are not +stored as runtime credentials. + +### Install the Linear Agent + +After the Linear bot Worker is deployed, visit: + +```text +https://open-inspect-linear-bot-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/oauth/authorize +``` + +A Linear workspace admin must approve the installation. After installation, the agent appears in +mention and assignment menus. Before testing, bind the external Linear team in Open-Inspect's +**Teams > your team > Channels**. In **Settings > Integrations > Linear**, save **Unbound Linear +teams**: `workspace` (the default, **Create workspace-level sessions**) or `reject` (**Reject +requests until bound**). This workspace-wide integration setting is named `unboundChannels`; it is +not an environment variable or Terraform input. Workspace fallback still must satisfy other creation +policies, including `requireTeamOnCreate`. + +Test by mentioning the agent on an issue in the bound Linear team, then use **View Session** to +verify the corresponding Open-Inspect session has the intended owning team and repository. + +For upgrades, enable **Client credentials tokens** before deploying. No reinstall is expected for an +eligible existing installation, but allow already-running sessions to finish before upgrading +because older callback contexts may not contain the installed app-user identity. + +For configuration and troubleshooting, see [Linear Integration](./integrations/LINEAR.md). + --- -## Step 7c: Complete GitHub Bot Setup (If Using GitHub Bot) +## GitHub Bot (Optional) + +The GitHub bot handles PR review assignments and @mention commands. To deploy it, set these in +`terraform.tfvars`, then run `terraform apply`: + +```hcl +enable_github_bot = true +github_webhook_secret = "your-generated-value" # From Step 4 +github_bot_username = "my-app[bot]" # See "Find Your Bot Username" below +``` -Now that the GitHub bot worker is deployed, configure the GitHub App for webhook delivery. +The GitHub App also needs the GitHub bot permissions listed in Step 3 (Actions, Checks, and Issues). + +After the GitHub bot worker is deployed, configure the GitHub App for webhook delivery. ### Configure Webhook on GitHub App @@ -887,36 +1261,9 @@ For day-to-day workflows, see [GitHub Integration](./integrations/GITHUB.md). --- -## Step 7d: Install the Linear Agent (If Using Linear) - -After the Linear bot Worker is deployed, visit: - -```text -https://open-inspect-linear-bot-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/oauth/authorize -``` - -A Linear workspace admin must approve the installation. After installation, the agent appears in -mention and assignment menus. Test it by mentioning the agent on an issue, then use **View Session** -to follow the corresponding Open-Inspect session. - -For upgrades, enable **Client credentials tokens** before deploying. No reinstall is expected for an -eligible existing installation, but allow already-running sessions to finish before upgrading -because older callback contexts may not contain the installed app-user identity. - -For configuration and troubleshooting, see [Linear Integration](./integrations/LINEAR.md). - ---- - -## Step 8: Deploy the Web App +## Custom Domain for the Web App (Optional) -### If using Cloudflare (`web_platform = "cloudflare"`) - -Terraform handles the full build and deploy automatically — the web app is built with OpenNext and -deployed as a Cloudflare Worker during `terraform apply`. No manual step needed. - -#### Optional: serve the web app on a custom domain - -By default the web app is served from +Custom domains require `web_platform = "cloudflare"`. By default the web app is served from `https://open-inspect-web-{deployment_name}.YOUR-SUBDOMAIN.workers.dev`. To use your own hostname, set both of these in `terraform.tfvars`: @@ -935,74 +1282,9 @@ Cloudflare provisions the DNS record and edge certificate automatically. Notes: - The Cloudflare API token needs zone-level **Workers Routes: Edit** permission to attach the domain. -### If using Vercel (`web_platform = "vercel"`) - -Terraform creates the Vercel project and configures environment variables, but does **not** deploy -the code. You have two options: - -#### Option A: Deploy via CLI (Recommended for First Deploy) - -```bash -# From the repository root (replace {deployment_name} with your value from terraform.tfvars) -npx vercel link --project open-inspect-{deployment_name} -npx vercel --prod -``` - -> **Note**: The Vercel project is configured with custom build commands for the monorepo structure. -> Terraform sets these automatically: -> -> - Install: `cd ../.. && npm install && npm run build -w @open-inspect/shared` -> - Build: `next build` - -#### Option B: Link Git Repository (For Automatic Deployments) - -1. Go to [Vercel Dashboard](https://vercel.com/dashboard) -2. Find the `open-inspect-{deployment_name}` project -3. Go to **Settings → Git** -4. Click **"Connect Git Repository"** and select your fork -5. Vercel will automatically deploy on push to main - -> **Note**: If you link Git, ensure the build settings match those configured by Terraform (Settings -> → General → Build & Development Settings). - ---- - -## Step 9: Verify Deployment - -After deployment completes, verify each component: - -```bash -# Get the verification commands from Terraform -terraform output verification_commands -``` - -Or manually: - -```bash -# 1. Control Plane health check (replace {deployment_name} and YOUR-SUBDOMAIN) -curl https://open-inspect-control-plane-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/health - -# 2. Sandbox backend health check -# Modal exposes a health endpoint. Prefer the exact URL from terraform output verification_commands. -# Manual form: https://[-]--open-inspect-api-health.modal.run -MODAL_WORKSPACE_SLUG="YOUR-WORKSPACE" # or "YOUR-WORKSPACE-YOUR-MODAL-WEB-SUFFIX" -curl https://${MODAL_WORKSPACE_SLUG}--open-inspect-api-health.modal.run -# Daytona and Vercel use their provider APIs directly, so there is no Open-Inspect shim health URL. - -# 3. Web app (should return 200) -curl -I "$(terraform output -raw web_app_url)" -``` - -### Test the Full Flow - -1. Visit your web app URL -2. Sign in with each configured provider -3. Create a new session with a repository -4. Send a prompt and verify the sandbox starts - --- -## Step 10: Set Up CI/CD (Optional) +## Set Up CI/CD (Optional) Enable automatic deployments when you push to main by configuring GitHub Actions secrets and variables under your fork's **Settings → Secrets and variables → Actions**. @@ -1017,12 +1299,22 @@ WEB_PLATFORM SANDBOX_PROVIDER CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_WORKER_SUBDOMAIN +R2_MEDIA_LOCATION +R2_MEDIA_BUCKET_NAME +SANDBOX_INACTIVITY_TIMEOUT_MS +SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS +SANDBOX_BOOT_TIMEOUT_MS ENABLE_DURABLE_OBJECT_BINDINGS +ENABLE_SERVICE_BINDINGS # Vercel web app VERCEL_TEAM_ID VERCEL_PROJECT_ID +# Documentation site (packages/docs) +DOCS_SITE_ENABLED +DOCS_CUSTOM_DOMAIN + # Modal MODAL_WORKSPACE MODAL_ENVIRONMENT @@ -1040,17 +1332,22 @@ ENABLE_LINEAR_BOT LINEAR_CLIENT_ID # Access control and branding +TEAMS_ENFORCEMENT ALLOWED_USERS ALLOWED_EMAIL_DOMAINS ALLOWED_EMAILS ALLOWED_GITHUB_ORGS +UNSAFE_ALLOW_ALL_USERS APP_NAME APP_ICON_URL # Daytona DAYTONA_API_URL DAYTONA_BASE_SNAPSHOT +DAYTONA_BASE_SNAPSHOT_MEMORY_GIB DAYTONA_TARGET +DAYTONA_TOOLBOX_API_URL +DAYTONA_PREBUILDS_ENABLED # Vercel Sandbox VERCEL_SANDBOX_PROJECT_ID @@ -1060,12 +1357,6 @@ VERCEL_BASE_SNAPSHOT_ID VERCEL_SANDBOX_RUNTIME VERCEL_SNAPSHOT_EXPIRATION_MS -# OpenComputer -OPENCOMPUTER_API_URL -OPENCOMPUTER_TEMPLATE -OPENCOMPUTER_PROJECT_ID -OPENCOMPUTER_TARGET - # E2B E2B_TEMPLATE_ID E2B_API_URL @@ -1073,6 +1364,12 @@ E2B_SANDBOX_TIMEOUT_SECONDS E2B_AUTO_PAUSE E2B_TEMPLATE_CPU E2B_TEMPLATE_MEMORY_MB + +# Boat +BOAT_BASE_SNAPSHOT +BOAT_API_URL +BOAT_ORG +BOAT_SANDBOX_TYPE ``` These settings resolve as **non-empty variable → same-named secret → existing default**, where a @@ -1080,6 +1377,10 @@ workflow default exists. Existing secret-only deployments need no migration. If variable wins; delete it to return to the secret. An empty variable does not clear an existing secret. Values such as `false` and `0` are strings in Actions variables and are preserved. +For Teams, both Terraform plan and apply use the repository variable `TEAMS_ENFORCEMENT`, then the +same-named secret, then `shadow`. Set the variable to `on` when enabling non-private team read +enforcement through CI; a local `terraform.tfvars` choice alone does not configure the workflow. + Keep credentials in the **Secrets** tab: API tokens/keys, OAuth client secrets, signing secrets, private keys, encryption keys, and both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. Allowlist values may contain personal information; leave them in secrets if you prefer masking in workflow logs. @@ -1087,75 +1388,118 @@ may contain personal information; leave them in secrets if you prefer masking in The table below describes deployment settings and credentials; use Variables for the names above and Secrets for credentials: -| Setting Name | Value | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | -| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token | -| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | -| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain | -| `DEPLOYMENT_NAME` | Your deployment name | -| `R2_ACCESS_KEY_ID` | R2 access key ID | -| `R2_SECRET_ACCESS_KEY` | R2 secret access key | -| `WEB_PLATFORM` | `vercel` or `cloudflare` | -| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | -| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | -| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | -| `MODAL_TOKEN_ID` | Modal token ID | -| `MODAL_TOKEN_SECRET` | Modal token secret | -| `MODAL_WORKSPACE` | Modal workspace name | -| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | -| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | -| `SANDBOX_PROVIDER` | `modal`, `daytona`, or `vercel` | -| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_TARGET` | Optional Daytona target name | -| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | -| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | -| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | -| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds | -| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) | -| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) | -| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override | -| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` | -| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` | -| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` | -| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` | -| `GH_APP_ID` | Required GitHub App repository-access ID | -| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) | -| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID | -| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) | -| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) | -| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) | -| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) | -| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) | -| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) | -| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) | -| `ANTHROPIC_API_KEY` | Optional; reaches Modal and OpenComputer sandboxes; required by an Anthropic classifier | -| `CLASSIFICATION_OPENAI_API_KEY` | Classifier OpenAI key (required when `classification_model` is an OpenAI id) | -| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication | -| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication | -| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) | -| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | -| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | -| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted | -| `MODAL_API_SECRET` | Generated Modal API secret | -| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | -| `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) | -| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) | -| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | -| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | -| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) | -| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | -| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | -| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | -| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) | -| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) | +| Setting Name | Value | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token | +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | +| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain | +| `R2_MEDIA_LOCATION` | R2 location hint for the media bucket (defaults to `ENAM`) | +| `R2_MEDIA_BUCKET_NAME` | Optional media bucket name override for a pre-created bucket | +| `DEPLOYMENT_NAME` | Your deployment name | +| `TEAMS_ENFORCEMENT` | Session enforcement: `off`, `shadow` (default), or `on`; prefer a repository variable | +| `R2_ACCESS_KEY_ID` | R2 access key ID | +| `R2_SECRET_ACCESS_KEY` | R2 secret access key | +| `WEB_PLATFORM` | `vercel` or `cloudflare` | +| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | +| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | +| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | +| `DOCS_SITE_ENABLED` | `true` keeps the docs site Vercel project (default: `false`; see `packages/docs/README.md`) | +| `DOCS_CUSTOM_DOMAIN` | Optional docs site hostname, e.g. `docs.example.com` (default: the vercel.app URL only) | +| `MODAL_TOKEN_ID` | Modal token ID | +| `MODAL_TOKEN_SECRET` | Modal token secret | +| `MODAL_WORKSPACE` | Modal workspace name | +| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | +| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | +| `SANDBOX_PROVIDER` | `modal` (default), [`modal-vm`](MODAL_DOCKER.md), `daytona`, `vercel`, `e2b`, or `boat` | +| `SANDBOX_INACTIVITY_TIMEOUT_MS` | Regular sandbox idle timeout in milliseconds for user, Slack, and Linear sessions (defaults to `600000`) | +| `SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS` | Sandbox idle timeout in milliseconds for automation, GitHub bot, and agent child sessions (defaults to `120000`, minimum `60000`) | +| `SANDBOX_BOOT_TIMEOUT_MS` | Milliseconds a connected sandbox may keep booting before it fails (defaults to `1800000`) | +| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name prefix _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_BASE_SNAPSHOT_MEMORY_GIB` | Base snapshot memory in GiB (defaults to `2`) | +| `DAYTONA_TARGET` | Optional Daytona target name | +| `DAYTONA_TOOLBOX_API_URL` | Optional Daytona toolbox proxy override; empty uses the proxy each sandbox reports | +| `DAYTONA_PREBUILDS_ENABLED` | `true` to admit new Daytona prebuilt-image builds and boot from them (default: `false`) | +| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | +| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds | +| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) | +| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) | +| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override | +| `E2B_API_KEY` | E2B API key _(only if `sandbox_provider = "e2b"`)_ | +| `BOAT_API_KEY` | Boat API key _(only if `sandbox_provider = "boat"`)_ | +| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` | +| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` | +| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` | +| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` | +| `GH_APP_ID` | Required GitHub App repository-access ID | +| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) | +| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID | +| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) | +| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) | +| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) | +| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) | +| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) | +| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) | +| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) | +| `LINEAR_API_KEY` | Optional Linear API key used as a comment-posting fallback | +| `ANTHROPIC_API_KEY` | Optional; reaches Modal sandboxes; classifier fallback | +| `CLASSIFICATION_ANTHROPIC_API_KEY` | Optional classifier-only Anthropic key; never reaches sandboxes | +| `CLASSIFICATION_OPENAI_API_KEY` | Classifier OpenAI key (required when `classification_model` is an OpenAI id) | +| `CLASSIFICATION_BEDROCK_API_KEY` | Classifier Amazon Bedrock API key (required when `classification_model` is a Bedrock id) | +| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication | +| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication | +| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) | +| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | +| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | +| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted | +| `MODAL_API_SECRET` | Generated Modal API secret | +| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | +| `ALLOWED_USERS` | Comma-separated GitHub usernames (empty = list unused; see note below) | +| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (empty = list unused; see note below) | +| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | +| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | +| `UNSAFE_ALLOW_ALL_USERS` | `true` to allow any authenticated user when every allowlist is empty (defaults to `false`) | +| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) | +| `ENABLE_SERVICE_BINDINGS` | Optional Terraform CI flag for service-binding phase 1 (defaults to `true`) | +| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | +| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | +| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | +| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) | +| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) | + +For sessions with source `automation`, `github-bot`, or `agent` (including nested agent children), +the effective idle timeout is +`min(SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS, SANDBOX_INACTIVITY_TIMEOUT_MS)`. Set the unattended +value at or above the regular timeout to disable the shorter timeout. Terraform and the control +plane reject unattended values below `60000` ms. User (`user`), Slack (`slack-bot`), and Linear +(`linear-bot`) sessions keep the regular 10-minute default. For local Terraform deployments, the +inputs are `sandbox_inactivity_timeout_ms` and `sandbox_unattended_inactivity_timeout_ms`. + +A connected session-page WebSocket keeps the existing idle-timer extensions and warnings, including +for unattended sessions. Direct terminal, VS Code, or VNC connections alone do not extend the timer. +Runtime heartbeats renew activity while a prompt is processing, so quiet running turns are not +treated as idle. A follow-up prompt after an inactivity stop resumes the existing session and +restores or resumes its sandbox rather than creating a new session. + +An empty allowlist only means that list is not used; it does not admit everyone. Terraform fails the +plan unless at least one of `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, `ALLOWED_EMAILS`, or +`ALLOWED_GITHUB_ORGS` is set, or `UNSAFE_ALLOW_ALL_USERS` is `true`. Each enabled sign-in provider +also needs a compatible allowlist; see [Choose Sign-In Providers](#choose-sign-in-providers). `CLASSIFICATION_MODEL` is an optional Actions **variable**, not a secret — add it under Settings → Secrets and variables → Actions → _Variables_ to point the Slack/Linear classifiers at a different model (for example `gpt-5.4-mini`). Leave it unset to keep the Terraform default. An OpenAI value -also requires the `CLASSIFICATION_OPENAI_API_KEY` secret; an Anthropic value is served by -`ANTHROPIC_API_KEY`. +also requires the `CLASSIFICATION_OPENAI_API_KEY` secret, and a Bedrock value +(`bedrock//`) requires the `CLASSIFICATION_BEDROCK_API_KEY` +secret; an Anthropic value is served by `CLASSIFICATION_ANTHROPIC_API_KEY`, falling back to +`ANTHROPIC_API_KEY`. To keep the classifier key out of Modal sandboxes, set +`CLASSIFICATION_ANTHROPIC_API_KEY` and leave `ANTHROPIC_API_KEY` unset; sandboxes then take model +credentials from Open-Inspect's secret store. The optional `CLASSIFICATION_REASONING_EFFORT` +variable sets the reasoning effort an OpenAI classifier requests (for example `low`); leave it unset +to use the model's default. When enabling or upgrading the Linear bot, also enable **Client credentials tokens** on the OAuth application in **Linear Settings → API → Applications**. This provider-side setting is not managed @@ -1211,6 +1555,38 @@ it is the recovery source for automatically generated keys. --- +## Customizing the App Name and Icon (Optional) + +Open-Inspect can be whitelabeled by overriding the brand name and logo. Both values are optional and +default to the built-in `Open-Inspect` brand. + +Add these to your `terraform.tfvars`: + +```hcl +# Display name shown in: +# - Web tab title, sign-in page, landing hero +# - Slack App Home settings page +# - Linear OAuth success page and completion comments +# - PR body footer ("Created with []()") +# - Outbound HTTP User-Agent headers (GitHub, GitLab API) +app_name = "Acme Bot" + +# Optional URL to a custom logo image (SVG/PNG). When set, replaces the icon in +# the command menu and favicon. Leave empty to keep the built-in favicon. +# Use an absolute URL or a root-relative path served from packages/web/public/. +app_icon_url = "/branding/acme-logo.svg" # or "https://cdn.example.com/logo.svg" +``` + +After changing any of these values, run `terraform apply` and (for Vercel) redeploy the web app so +the new build picks up the `NEXT_PUBLIC_APP_NAME` and `NEXT_PUBLIC_APP_ICON_URL` env vars +(Cloudflare's web deploy is rebuilt automatically by Terraform). + +> **Note**: `NEXT_PUBLIC_*` vars are inlined into the client bundle at build time, so changes +> require a fresh web build. The bot/control-plane workers read `APP_NAME` at request time, so they +> pick up the new value immediately after `terraform apply`. + +--- + ## Updating Your Deployment To update after pulling changes from upstream: @@ -1373,7 +1749,8 @@ that set no fleet-wide key in Terraform supply model credentials as secrets inst 1. Go to **Settings > Secrets** in the web app 2. Select **All Repositories (Global)** from the scope dropdown 3. Add the key for your chosen provider (e.g., `ANTHROPIC_API_KEY` for Claude models or - `DEEPSEEK_API_KEY` for DeepSeek models, or `ZHIPU_API_KEY` for Z.AI Coding Plan models) + `DEEPSEEK_API_KEY` for DeepSeek models, `ZHIPU_API_KEY` for Z.AI Coding Plan models, or + `OPENCODE_API_KEY` for OpenCode Zen and OpenCode Go models) 4. Click **Save** See [Secrets Management](SECRETS.md) for more on global and repository secrets. @@ -1407,38 +1784,6 @@ This occurs on first deployment. Follow the two-phase deployment process: --- -## Customizing the App Name and Icon (Optional) - -Open-Inspect can be whitelabeled by overriding the brand name and logo. Both values are optional and -default to the built-in `Open-Inspect` brand. - -Add these to your `terraform.tfvars`: - -```hcl -# Display name shown in: -# - Web tab title, sign-in page, landing hero -# - Slack App Home settings page -# - Linear OAuth success page and completion comments -# - PR body footer ("Created with []()") -# - Outbound HTTP User-Agent headers (GitHub, GitLab API) -app_name = "Acme Bot" - -# Optional URL to a custom logo image (SVG/PNG). When set, replaces the icon in -# the command menu and favicon. Leave empty to keep the built-in favicon. -# Use an absolute URL or a root-relative path served from packages/web/public/. -app_icon_url = "/branding/acme-logo.svg" # or "https://cdn.example.com/logo.svg" -``` - -After changing any of these values, run `terraform apply` and (for Vercel) redeploy the web app so -the new build picks up the `NEXT_PUBLIC_APP_NAME` and `NEXT_PUBLIC_APP_ICON_URL` env vars -(Cloudflare's web deploy is rebuilt automatically by Terraform). - -> **Note**: `NEXT_PUBLIC_*` vars are inlined into the client bundle at build time, so changes -> require a fresh web build. The bot/control-plane workers read `APP_NAME` at request time, so they -> pick up the new value immediately after `terraform apply`. - ---- - ## Architecture Reference For details on the infrastructure components, see: diff --git a/docs/GROK_MODELS.md b/docs/GROK_MODELS.md index e08af9ac3d..fd75242cb1 100644 --- a/docs/GROK_MODELS.md +++ b/docs/GROK_MODELS.md @@ -48,7 +48,7 @@ while its absence leaves `XAI_API_KEY` available as the compatibility fallback. ### Step 3: Enable and Select Grok 1. Open **Settings > Models**. -2. Enable **Grok 4.6**, **Grok 4.5**, or **Grok Build 0.1** under **xAI / SuperGrok**. +2. Enable **Grok 4.7**, **Grok 4.6**, **Grok 4.5**, or **Grok Build 0.1** under **xAI / SuperGrok**. 3. Create a new session. 4. Select the enabled Grok model and desired reasoning effort. 5. Use **xAI authentication** to follow provider policy, select a specific account, or choose **Use @@ -109,8 +109,8 @@ are no longer needed. See [Using OpenAI Models](OPENAI_MODELS.md#deployment-and- ### Grok does not appear in the model selector -Enable **Grok 4.6**, **Grok 4.5**, or **Grok Build 0.1** under **Settings > Models**. The xAI group -is opt-in and is not part of the default enabled model set. +Enable **Grok 4.7**, **Grok 4.6**, **Grok 4.5**, or **Grok Build 0.1** under **Settings > Models**. +The xAI group is opt-in and is not part of the default enabled model set. ### Session uses API-key mode unexpectedly @@ -122,7 +122,7 @@ mode. Without a default or explicit choice, new sessions preserve legacy scoped The refresh token was revoked, expired, or already rotated elsewhere. Use **Reconnect** on the provider account and complete xAI device authorization again. -### `Model not found: xai/grok-4.6`, `xai/grok-4.5`, or `xai/grok-build-0.1` +### `Model not found: xai/grok-4.7`, `xai/grok-4.6`, `xai/grok-4.5`, or `xai/grok-build-0.1` Rebuild the sandbox image so it includes the xAI auth proxy plugin and confirm the deployment uses OpenCode 1.17.18 or newer. diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index 8804c47fe8..1b46a710fa 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -69,9 +69,10 @@ Slack sessions can target an environment three ways: a routing rule (Settings Slack) launches it from a keyword; a channel association (`channelAssociations` on the environments API, like repository metadata) routes messages in that channel to it automatically; and the LLM classifier considers environments alongside repositories, using their names and descriptions as -signals — its clarification picker lists both kinds when it has to ask. Linear sessions can target -an environment through the team and project mappings (`{"environmentId": "env_…"}` entries alongside -repository entries). +signals. The classifier can also select no repository when the task does not require a codebase. Its +clarification picker always includes **No repository** alongside accessible repositories and +environments. Linear sessions can target an environment through the team and project mappings +(`{"environmentId": "env_…"}` entries alongside repository entries). ### Session Lifecycle @@ -106,14 +107,24 @@ high performance even with hundreds of concurrent sessions. An **environment** is a named, reusable set of repositories — the thing you reach for when the same multi-repository workspace comes up again and again (a frontend + its API, a service + its shared library). Environments are managed under **Settings > Environments** and appear at the top of the -new-session picker. +new-session picker when accessible. They can be workspace-owned or team-owned, with names unique +within that ownership scope. Ownership is immutable. Team pages also list their own environments. + +Team environments require membership in the owning team or a workspace Owner/Administrator role, +plus the appropriate environment permission. Managing one requires a lead or workspace +Owner/Administrator and `environments.manage`; secrets, settings, and image mutations also require +their respective feature permissions. A team environment can launch only into a session owned by +that team. A team's session catalog can include eligible workspace environments too, provided its +repository grants cover their members. Unbound bot catalogs do not expose team environments. +`requireTeamOnCreate`, when enabled, also requires a team for new environment definitions. See +[Authentication and Authorization](AUTH.md#environment-access). An environment defines: - **An ordered repository list** (up to 10) with a base branch per repository; the first repository is the primary -- **Environment secrets** — sessions launched from the environment receive global secrets plus the - environment's secrets (repository secrets do not flow in; see +- **Environment secrets** — sessions receive global secrets, then their owning team's secrets (if + any), then the environment's secrets; later scopes win. Repository secrets do not flow in (see [Secrets Management](./SECRETS.md#which-secrets-a-session-receives)) - **Optional prebuilt images** — the whole environment (all clones + all setup scripts) is built ahead of time so sessions boot in seconds (see [Pre-Built Images](./IMAGE_PREBUILD.md)) @@ -199,7 +210,7 @@ development environment. **What's in a sandbox:** - Debian Linux with common dev tools -- Node.js 22, Python 3.12, git, curl +- Node.js 24, Python 3.12, git, curl - Package managers: npm, pnpm, pip, uv - agent-browser CLI + headless Chrome (for browser automation) - OpenCode and the Claude Agent SDK (the coding agent harnesses) @@ -210,14 +221,14 @@ Open-Inspect supports these sandbox backends: - **Daytona**: persistent stop/start sandboxes via direct REST API calls - **Vercel Sandboxes**: filesystem snapshot restore and prebuilt-image builds via the Vercel Sandbox API -- **OpenComputer**: template-based sandboxes with checkpoint-backed prebuilt-image builds via the - OpenComputer REST API - **E2B**: template-based sandboxes with persistent pause/resume via direct E2B REST API calls +- **Boat**: full-VM sandboxes deployed from a saved snapshot, archived on inactivity and resumed on + a new machine via direct Boat REST API calls -Prebuilt-image builds are supported on Modal, Vercel, and OpenComputer. Saved filesystem state can -be restored on those same providers for session resumes; Daytona and E2B use persistent sandboxes -instead. For Daytona and E2B, the control plane stops or pauses the sandbox on inactivity or stale -heartbeat, then resumes that same sandbox later. +Prebuilt-image builds are supported on Modal, Vercel, E2B, and Daytona (opt-in); Boat has none. +Saved filesystem state can be restored on Modal and Vercel for session resumes; Daytona, E2B, and +Boat use persistent sandboxes instead. For those three, the control plane stops, pauses, or archives +the sandbox on inactivity or stale heartbeat, then resumes that same sandbox later. ### Clients @@ -247,43 +258,101 @@ cloud. When you create a session for a repo without an existing snapshot: ``` -┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────┐ -│ Sandbox │───▶│ Git Sync │───▶│ Setup Script│───▶│ Start Script│───▶│ Agent Start │───▶│ Ready │ -│ Created │ │ (clone) │ │ (optional) │ │ (optional) │ │ (OpenCode) │ │ │ -└─────────┘ └──────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └───────┘ - │ │ - ▼ ▼ - .openinspect/setup.sh .openinspect/start.sh +┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ +│ Sandbox │──▶│ Bridge │──▶│ Git Sync │──▶│ Setup Script │──▶│ Start Script │──┐ +│ Created │ │ Starts │ │ (clone) │ │ (optional) │ │ (optional) │ │ +└─────────┘ └──────────┘ └──────────┘ └──────────────┘ └──────────────┘ │ + "starting" "sync" "setup" "start" │ + .openinspect/setup.sh .openinspect/start.sh + ┌───────────────────────────────────────────────────────────────────────────────┘ + │ ┌────────────────┐ ┌─────────────┐ ┌───────┐ + └──▶│ Managed Skills │──▶│ Agent Start │──▶│ Ready │ + └────────────────┘ └─────────────┘ └───────┘ + "skills" "harness" ``` 1. **Sandbox created**: The selected backend creates a fresh sandbox from its base runtime -2. **Git sync**: Clones your repository using brokered SCM credentials from the git credential - helper -3. **Setup script**: Runs `.openinspect/setup.sh` for provisioning (if present) -4. **Start script**: Runs `.openinspect/start.sh` for runtime startup (if present) -5. **Agent start**: OpenCode server starts and connects back to the control plane -6. **Ready**: Sandbox accepts prompts - -For multi-repository sessions, steps 2–4 run per repository in position order: every repository is -cloned into its own `/workspace` directory and each repository's setup and start scripts run in -sequence. +2. **Bridge starts**: The runtime starts its bridge process ahead of the repository boot, and the + bridge opens its WebSocket to the control plane and reports the `starting` phase. The supervisor + does not wait for that handshake, so the first boot steps can begin a moment before the socket is + up; in practice it connects a second or two into the boot. Once connected the sandbox is + `connecting`: it sends a heartbeat every 30 seconds and reports every later phase as it starts + and completes, so the control plane can tell a long boot from a dead one. A prompt sent during + boot waits here. +3. **Git sync** (`sync`): Clones your repository using brokered SCM credentials from the git + credential helper +4. **Setup script** (`setup`): Runs `.openinspect/setup.sh` for provisioning (if present). A + non-zero exit is reported as a warning and the boot continues +5. **Start script** (`start`): Runs `.openinspect/start.sh` for runtime startup (if present) +6. **Managed skills** (`skills`): Installs the session's managed skills into the workspace +7. **Agent start** (`harness`): The agent harness starts (the OpenCode server, or the Claude Agent + staging). The bridge attaches to it and sends `ready` +8. **Ready**: The runtime's `ready` event, not the connection, marks the sandbox ready. Prompts that + were waiting dispatch now + +For multi-repository sessions the three steps have different shapes. Git sync is a single phase +covering the whole set — the repositories are cloned concurrently into `/workspace/` — so +it names no individual repository. Setup then runs for every repository in position order, and start +runs as a second pass in the same order. Each `setup` and `start` phase names the repository it is +running for. + +#### Watching a boot + +The session header names the phase while it runs: "Cloning repository", "Running setup.sh", +"Starting services", "Installing skills", "Starting agent". Multi-repository sessions add the +repository, as in "Running setup.sh for acme/api". Between phases, its status popover can say what +just finished, but it does not display a list of completed phases or their durations. When a script +fails, the header's status popover says which phase failed and names its repository when available. +Failure reports retain phase, repository, and error or warning metadata, but hook stdout and stderr +are discarded rather than collected or shown. A fatal `start.sh` failure in the session's first +repository ends the boot. A `setup.sh` failure, and a `start.sh` failure in a later repository, are +tolerated instead: the boot continues and the phase completes carrying a warning. + +#### How long a boot may take + +There is no fixed limit on `setup.sh` or `start.sh`. Two bounds apply instead: + +- **Connect watchdog (4 minutes)**: measured from sandbox creation until the bridge first connects. + It covers the provider launching the container, not your scripts. +- **Boot budget (30 minutes by default)**: measured from sandbox creation until `ready`. Set + `SANDBOX_BOOT_TIMEOUT_MS` to change it (see [Getting Started](./GETTING_STARTED.md)). When the + budget runs out the sandbox is failed and its credentials are revoked, the prompt that was waiting + fails with the phase that was running, and the next prompt starts a new sandbox. + +A bridge that stops sending heartbeats for 90 seconds during boot is treated as dead the same way. +No snapshot is taken in any of these cases, so a half-provisioned workspace never becomes the +restore point. The sandbox itself is stopped only on providers that can stop one explicitly; on the +others the row is marked stale and its socket detached, and the sandbox ages out on the provider's +own timeout. ### Restore (From Snapshot) When restoring from a previous snapshot: ``` -┌─────────────┐ ┌────────────┐ ┌─────────────┐ ┌───────┐ -│ Restore │───▶│ Quick Sync │───▶│ Start Script│───▶│ Ready │ -│ Snapshot │ │ (git pull) │ │ (optional) │ │ │ -└─────────────┘ └────────────┘ └─────────────┘ └───────┘ +┌─────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────┐ +│ Restore │──▶│ Bridge │──▶│ Quick Sync │──▶│ Start Script │──▶│ Agent Start │──▶│ Ready │ +│ Snapshot │ │ Starts │ │(git fetch) │ │ (optional) │ │ │ │ │ +└─────────────┘ └──────────┘ └────────────┘ └──────────────┘ └─────────────┘ └───────┘ ``` 1. **Restore snapshot**: The selected snapshot-capable provider restores the filesystem from a saved - snapshot or checkpoint -2. **Quick sync**: Pulls latest changes (usually just a few commits) -3. **Start script**: Runs `.openinspect/start.sh` for runtime startup (if present) -4. **Ready**: Sandbox is ready almost instantly + snapshot +2. **Bridge connects**: As in a fresh start, the bridge connects first and reports each phase +3. **Quick sync**: Fetches the session's branch from origin. The restored checkout is left as it is, + so the snapshot's commit and any uncommitted work survive the restore +4. **Start script**: Runs `.openinspect/start.sh` for runtime startup (if present) +5. **Agent start**: Managed skills are installed and the agent harness starts +6. **Ready**: Sandbox is ready almost instantly + +A restore reports the same phases as a fresh start minus `setup`, so the header shows the same +labels. + +A snapshot taken by an older runtime still restores: the control plane accepts any runtime from +generation 62 up, and connecting before the boot arrived in generation 68. A session restored from a +generation 62–67 snapshot therefore boots the old way — its bridge connects only once the repository +boot has finished — so it reports no phases while it boots and the whole boot still has to fit +inside the four-minute connect watchdog. Snapshots include installed dependencies, built artifacts, and workspace state. This is why follow-up prompts in an existing session are much faster than the first prompt. @@ -293,11 +362,15 @@ follow-up prompts in an existing session are much faster than the first prompt. When starting from a pre-built image (built for the session's repository or, for sessions launched from a prebuild-enabled environment, the environment's whole repository set): -1. **Incremental git sync**: Fast fetch + hard reset to latest branch head (per repository for +1. **Bridge connects**: As in a fresh start, the bridge connects first and reports each phase +2. **Incremental git sync**: Fast fetch + hard reset to latest branch head (per repository for multi-repository sets) -2. **Setup skipped**: `.openinspect/setup.sh` already ran when the image was built -3. **Start script runs**: `.openinspect/start.sh` executes for per-session runtime startup -4. **Ready**: Agent starts once runtime hook succeeds +3. **Setup skipped**: `.openinspect/setup.sh` already ran when the image was built, so no `setup` + phase is reported +4. **Start script runs**: `.openinspect/start.sh` executes for per-session runtime startup +5. **Managed skills** (`skills`): The session's managed skills are installed +6. **Agent start** (`harness`): The agent harness starts and the bridge attaches to it +7. **Ready**: The runtime sends `ready` once the harness is up If `start.sh` exists and fails, startup fails fast instead of continuing with a broken runtime. @@ -336,6 +409,10 @@ particularly with explicit `--package` commands. - **After successful prompt completion**: Preserves the workspace state - **Before sandbox timeout**: Saves state before the sandbox shuts down due to inactivity - **On explicit save**: Can be triggered by the control plane +- **Not on a failed boot**: None of the automatic boot-failure paths — connect watchdog, boot + budget, stale heartbeat, fatal runtime error — takes a snapshot, so a half-provisioned workspace + does not become the restore point that way. The runtime also refuses a `snapshot` command while it + is still booting ### Sandbox Warming @@ -518,14 +595,14 @@ Sessions stream events to all connected clients via WebSocket. ### Event Types -| Event | Description | -| ------------------ | --------------------------------------------- | -| `sandbox_spawning` | Sandbox is being created | -| `sandbox_ready` | Sandbox is ready to accept prompts | -| `sandbox_event` | Tool call, token stream, or other agent event | -| `artifact_created` | PR created, screenshot captured | -| `presence_update` | User joined or left the session | -| `session_status` | Session state changed | +| Event | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `sandbox_spawning` | Sandbox is being created | +| `sandbox_status` | Sandbox moved between `pending`, `spawning`, `connecting`, `warming`, `ready`, `snapshotting`, `stale`, `stopped`, and `failed` | +| `sandbox_event` | Tool call, token stream, boot phase (`boot_progress`), or other agent event | +| `artifact_created` | PR created, screenshot captured | +| `presence_update` | User joined or left the session | +| `session_status` | Session state changed | ### Multiplayer @@ -553,11 +630,13 @@ Without optimization, starting a session would require: 3. Installing dependencies (~30s-5min) 4. Starting the agent (~5s) -That's potentially minutes before the agent can start working. +That's potentially minutes before the agent can start working. Because the runtime connects before +it clones, you watch those steps happen phase by phase instead of waiting on a silent "Connecting" +indicator. ### How Snapshots Solve This -Provider snapshots and checkpoints let us capture a sandbox's state after setup: +Provider snapshots let us capture a sandbox's state after setup: ``` First session: Clone ─▶ Install/Build ─▶ Start Runtime ─▶ [Snapshot] ─▶ Work @@ -573,9 +652,7 @@ active provider supports saved filesystem state. For Vercel, Terraform builds a base-runtime snapshot from the local checkout and wires a deterministic snapshot name into `VERCEL_BASE_SNAPSHOT_NAME`. Fresh Vercel sandboxes resolve that name to the newest created snapshot instead of cloning and installing the sandbox runtime on every -session. OpenComputer uses a managed template plus checkpoints for the same prebuilt-image -lifecycle. See [Vercel Sandbox Provider](VERCEL_SANDBOX_PROVIDER.md) and -[OpenComputer Sandbox Provider](OPENCOMPUTER_PROVIDER.md) for provider-specific details. +session. See [Vercel Sandbox Provider](VERCEL_SANDBOX_PROVIDER.md) for provider-specific details. ### Image Prebuilding @@ -599,10 +676,11 @@ same organization. ### Why Single-Tenant? -The system uses a shared GitHub App installation for all git operations. This means: +GitHub git operations use a shared GitHub App installation. This means: -- Any user can access any repository the GitHub App is installed on -- There's no per-user repository access validation +- The installation defines the workspace's maximum repository reach; workspace permissions and team + repository grants further constrain access +- Open-Inspect does not compare a user's personal GitHub repository permissions with that scope - The trust boundary is your organization, not individual users This follows @@ -611,32 +689,30 @@ was built for internal use where all employees have access to company repositori ### Token Architecture -| Token | Purpose | Scope | -| ------------------ | ------------------------------------------ | -------------------------------- | -| GitHub App Token | Mint brokered git credentials | All repos where App is installed | -| User OAuth Token | Create PRs, identify users | Repos the user has access to | -| Sandbox Auth Token | Authenticate sandbox → control plane calls | Single session | -| WebSocket Token | Authenticate client connections | Single session | -| Managed LLM Token | Short-lived OpenAI or xAI model access | Pinned session provider account | - -Fresh and prebuilt-image sandboxes fetch git credentials on demand through the control plane instead -of relying on a token embedded in the environment or remote URL. Snapshot restores may still receive -env-token fallbacks so legacy snapshots can boot through the credential-helper migration. The helper -authorizes HTTPS requests for the configured SCM host, preserving existing setup/start hooks that -clone other private repositories available to the installation. This primarily protects continuously -running sessions and Daytona persistent resumes from expired embedded credentials; Modal snapshot -restores still mint a fresh fallback token on restore. +Session sandboxes fetch git credentials through the control plane and cache them on disk. GitHub +credentials cover the persisted session repositories, intersected with current owning-team grants +only for team-owned sessions. GitLab returns the deployment PAT without per-session narrowing. Modal +filesystem snapshots can retain the helper cache; brokerage is not a token-free snapshot guarantee, +and grant removal does not immediately revoke issued credentials. + +Image builds receive `VCS_CLONE_TOKEN` because they have no session broker. For GitHub it is scoped +to the build repositories, with current owning-team grants applied for team-owned environment +builds. For GitLab it is the deployment PAT, not a repository-scoped or single-use credential. See +the canonical [access and credential boundaries](AUTH.md#repository-and-credential-boundaries) for +dependency access, cache behavior, and provider limitations. ### Secrets -You can configure environment variables (API keys, credentials) at global, per-repository, or -per-environment scope. A session receives global secrets plus its **session target's** secrets: +You can configure environment variables (API keys, credentials) at global, team, repository, or +environment scope. Precedence is global, then owning team when present, then the **session +target's** secrets; later layers win collisions: - **Global secrets** apply to all sessions (e.g., `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, - `ZHIPU_API_KEY`) -- **Repository secrets** apply to sessions launched from that repo (including all bot-created - sessions) and override global secrets with the same key; ad-hoc multi-repository sessions receive - each selected repository's secrets, with the primary winning collisions + `ZHIPU_API_KEY`, `OPENCODE_API_KEY`) +- **Team secrets** apply to sessions owned by that team, overriding global values +- **Repository secrets** apply to repository-targeted sessions and override global and team secrets + with the same key; ad-hoc multi-repository sessions receive each selected repository's secrets, + with the primary winning collisions - **Environment secrets** apply to sessions launched from that environment — its repositories' repository secrets do not flow in - Stored encrypted (AES-256-GCM) in D1 database @@ -677,13 +753,14 @@ operators may remove legacy keys after legacy-bound sessions are no longer neede > **LLM API keys** (e.g., `ANTHROPIC_API_KEY` for Claude models) are added as global secrets. A > deployment can instead configure `anthropic_api_key` in Terraform to inject one fleet-wide key -> into Modal session sandboxes and OpenComputer sandboxes; a global secret of the same name takes -> precedence over it, and the other providers read only the secret store. +> into Modal session sandboxes; a global secret of the same name takes precedence over it, and the +> other providers read only the secret store. > -> **Opt-in model providers**: DeepSeek models require `DEEPSEEK_API_KEY`, and Z.AI Coding Plan -> models require `ZHIPU_API_KEY`, as a global secret with any sandbox provider. SuperGrok models -> require an xAI provider account or `XAI_API_KEY` mode and must be enabled under **Settings > -> Models**. +> **Opt-in model providers**: DeepSeek models require `DEEPSEEK_API_KEY`, Z.AI Coding Plan models +> require `ZHIPU_API_KEY`, and OpenCode Zen and OpenCode Go models require `OPENCODE_API_KEY` (Go +> also needs an active Go subscription on that key), as a global secret with any sandbox provider. +> SuperGrok models require an xAI provider account or `XAI_API_KEY` mode and must be enabled under +> **Settings > Models**. See [Secrets Management](./SECRETS.md) for setup instructions. diff --git a/docs/IMAGE_PREBUILD.md b/docs/IMAGE_PREBUILD.md index e92ec90d56..1016612182 100644 --- a/docs/IMAGE_PREBUILD.md +++ b/docs/IMAGE_PREBUILD.md @@ -34,10 +34,19 @@ few minutes of changes. ## Getting Started Pre-built images are available when the deployment uses `sandbox_provider = "modal"`, -`sandbox_provider = "vercel"`, `sandbox_provider = "opencomputer"`, or `sandbox_provider = "e2b"`. -The artifact is stored per provider as a Modal image, Vercel snapshot, OpenComputer checkpoint, or -E2B snapshot. Daytona deployments use persistent sandboxes instead; image settings are disabled for -that backend. +`sandbox_provider = "modal-vm"`, `sandbox_provider = "vercel"`, `sandbox_provider = "e2b"`, +`sandbox_provider = "daytona"`, or `sandbox_provider = "boat"`. The artifact is stored per provider +as a Modal image, Vercel snapshot, E2B/Daytona snapshot, or Boat saved snapshot. With `modal-vm`, +the artifact is a Modal image built on the VM backend. Images built under `modal` are not used by +`modal-vm` sessions. Boat's artifact has its own allowance and snapshot rules — see +[Boat prebuilds](#boat-prebuilds). + +Daytona additionally requires an operator to open admission (`daytona_prebuilds_enabled`, default +off) — see [Daytona prebuilds](#daytona-prebuilds) below. While admission is closed the settings +controls stay visible and say so, and nothing starts a build. Read every "triggers a build" +statement below as conditional on admission when the deployment is on Daytona: enabling a +repository, saving an environment, the manual rebuild button and the 30-minute scheduler all record +intent but start nothing until an operator opens it. On every other provider they are unconditional. ### Enable for a Repository @@ -56,6 +65,13 @@ the pre-built image automatically — no changes to your workflow needed. 3. Saving the environment triggers the first build immediately; you can also click the rebuild button on the environment row at any time +Saving the prebuild toggle and scheduling its build require environment management access. Manual +rebuilds additionally require `environments.images.manage`. For team environments, management +requires a team lead or workspace Owner/Administrator with `environments.manage`, and manual builds +require the active owning team's grants to cover every current repository. Another team's grants do +not substitute. These checks are independent of the session enforcement mode; repository images +remain workspace resources with their own grant checks. + ### What You'll See in the UI The Images settings page (and the environment rows under Settings > Environments) show the status of @@ -98,8 +114,10 @@ any of the following holds: - **No current image** — the scope was just enabled, its repository set or a base branch was edited (fingerprint mismatch), or the previous build failed - **New commits** — any repository's branch tip has moved since the ready image was built -- **Outdated runtime** — the image was built on a sandbox runtime older than the current - compatibility floor (such images are also skipped at spawn time) +- **Outdated runtime** — the image was built on a sandbox runtime older than the current **rebuild + floor**. That floor is separate from the **compatibility floor** spawn selection enforces: an + image between the two keeps serving sessions while the scheduler rebuilds it, and only one below + the compatibility floor is skipped at spawn time. The provider-neutral control-plane scheduler scans every enabled scope on each tick and starts every required rebuild it finds. Maintenance selects every pending terminal provider build session and old @@ -117,20 +135,26 @@ Builds also trigger immediately, outside the schedule, when: - You **change an environment's secrets** — this additionally retires the existing ready image before the rebuild, so rotated values can't keep serving from an old image (see [Secrets Management](SECRETS.md#secrets-and-prebuilt-images)) +- You **write or delete an owning team's secrets**: images for that team's environments are + invalidated, with best-effort rebuild scheduling for prebuild-enabled environments. Shared + repository images are unaffected because they never receive team secrets. - You click the **manual rebuild** button — next to the repository in Settings > Images, or on the environment row in Settings > Environments -Only one build runs per scope at a time; a trigger while a build is in flight is a no-op. +Only one build runs per scope at a time; a trigger while a build is in flight is a no-op. On a +Daytona deployment every trigger above — the scheduler included — starts a build only while +admission is open (see [Daytona prebuilds](#daytona-prebuilds)). ### What Happens During a Build The build process runs the same setup steps that a normal session would: -1. Clones every repository in the scope at its base branch (for an environment, **sequentially, in - position order**) -2. Runs each repository's `.openinspect/setup.sh` script (if present) in the same order -3. Calls the control plane with the exact bound provider session id -4. Lets a durable Queue consumer save the provider image artifact and terminate the build session +1. Clones every repository in the scope at its base branch (for an environment, **concurrently**) +2. Runs each repository's `.openinspect/setup.sh` script (if present) **sequentially, in position + order**, so a later repository's setup can rely on an earlier one's +3. Refreshes OpenCode's model catalog (see [Model catalog](#model-catalog)) +4. Calls the control plane with the exact bound provider session id +5. Lets a durable Jobs consumer save the provider image artifact and terminate the build session ```mermaid flowchart TD @@ -141,17 +165,17 @@ flowchart TD run -->|success callback| accept[Authenticate and persist completion metadata] run -->|failure callback| fail[Authenticate and persist failed state] - accept --> publish[Publish secret-free Queue command] + accept --> publish[Publish secret-free Jobs command] fail --> publish publish --> state{Accepted build state} - state -->|success: building| lease{D1 finalization lease available?} + state -->|success: building| lease{Finalization lease available?} state -->|failure: failed| cleanup[Terminate provider session] lease -->|no| retry[Retry after the active lease] retry --> lease - lease -->|yes| artifact[Snapshot or checkpoint provider session] + lease -->|yes| artifact[Snapshot provider session] - artifact --> fence[Fence provider artifact id in D1] + artifact --> fence[Fence provider artifact id in build store] fence --> ready[Mark image ready or superseded] ready --> cleanup cleanup --> done[Clear cleanup obligation] @@ -161,20 +185,44 @@ flowchart TD terminal --> cleanup ``` +#### Model catalog + +OpenCode reads its provider and model catalog from `~/.cache/opencode/models.json` when that file +exists, and otherwise from the catalog compiled into the pinned OpenCode binary. It downloads a +newer catalog in the background, but only the next OpenCode process reads it. Without a file on +disk, a fresh sandbox would never know about a model published after the pinned OpenCode release. + +The build runs `opencode models --refresh`, which writes the published catalog to that file, so +every session started from the image resolves models as of the build and session boots do no extra +work. The step is best-effort: a failure is logged and the build continues. OpenCode's own +environment variables (`OPENCODE_MODELS_URL`, `OPENCODE_MODELS_PATH`) apply to the refresh as they +do to sessions. + +Codex sessions price their token usage from the same file, so a model published after the image was +built has no price there: its turns are recorded as $0 with a timeline warning until the image is +rebuilt. + +The control plane publishes finalization through its `Jobs` port. Cloudflare delivers it with a +Queue and stores build state in D1; Node delivers it with the `jobs.db` poller and stores build +state in `global.db`. Both hosts run the same finalization handler and retry contract. + A failing setup script fails the whole build, and for environment builds the error names the -repository. Build-time secrets are exactly what the scope's sessions get: global + repository -secrets for a repository scope, global + environment secrets for an environment scope +repository. Repository builds receive global + repository secrets, **never team secrets**, because +their images are shared across teams. Environment builds receive global + the environment's owning +team (if any) + environment secrets; member-repository secrets do not flow in. Later scopes win. A +workspace environment build has no team layer, even when a team session later launches from it ([session-target scoping](SECRETS.md#which-secrets-a-session-receives)). Everything your setup scripts install — dependencies, build artifacts, caches — is captured in the image artifact. Depending on the active sandbox provider, this is stored as a Modal image, Vercel -snapshot, or OpenComputer checkpoint. +snapshot, E2B/Daytona snapshot, or Boat saved snapshot (on Boat, caches under `/tmp` and `~/.cache` +are the exception; see [Boat prebuilds](#boat-prebuilds)). The configured build timeout covers clone and setup execution. Build sandboxes receive an additional -ten minutes for callback delivery and snapshot/checkpoint finalization. Because Vercel limits -sandbox lifetime to 45 minutes, Vercel image-build execution is capped at 35 minutes so that reserve -is never lost; Modal and OpenComputer continue to honor the configured execution timeout up to the -shared one-hour limit. +ten minutes for callback delivery and snapshot finalization. Because Vercel limits sandbox lifetime +to 45 minutes, Vercel image-build execution is capped at 35 minutes so that reserve is never lost; +Modal, E2B, Daytona and Boat continue to honor the configured execution timeout up to the shared +one-hour limit. Modal follows the same lifecycle as the other providers: the control plane creates a dormant sandbox, records its id, starts the runtime, and snapshots it only after the callback has been @@ -188,7 +236,13 @@ the Worker until Modal's provider-session endpoints are available. A session boots from the ready image when the image's fingerprint matches the session's own repository snapshot (same repositories, same order, same base branches — for a single-repository -session that means the default branch): +session that means the default branch). + +Team environment images additionally require the session to have the same owning team, since setup +scripts may have persisted that team's secrets in the image. This check does not depend on session +visibility or the enforcement rollout mode. + +When an eligible image is ready: 1. The sandbox starts from the saved image artifact (code + dependencies already present) 2. A fast git sync pulls any commits pushed since the image was built (per repository) @@ -197,11 +251,14 @@ session that means the default branch): Your setup scripts are **not** re-run since they already ran during the build. This is the main source of time savings. -If no matching ready image is available (disabled, first build hasn't finished, the last build -failed, a non-default branch was selected, or the environment was edited after the session was -created), the session falls back to the normal startup flow automatically. If the saved artifact -itself fails to restore, the image is marked failed and the session retries from the base image. -Either way, you'll never be blocked from starting a session. +If no matching ready image is available (disabled, first build hasn't finished, no build has ever +succeeded, a non-default branch was selected, or the environment was edited after the session was +created), the session falls back to the normal startup flow automatically. A **failed rebuild does +not retire the image it was replacing**: an older ready image that still matches keeps serving +sessions until a newer build succeeds, unless already invalidated (for example, by an environment or +owning-team secret change). If the saved artifact itself fails to restore, the image is marked +failed and the session retries from the base image. Either way, you'll never be blocked from +starting a session. **Ad-hoc multi-repository sessions never use prebuilt images.** Picking "Multiple repositories" in the new-session picker always does a full clone + setup for each repository. If you use the same set @@ -209,6 +266,157 @@ regularly, save it as an environment and enable prebuilds. --- +## Daytona prebuilds + +Daytona builds and boots prebuilt images through the same subsystem as every other provider, with +two differences that come from how Daytona captures an image. + +**Capture is asynchronous, and it reads a stopped sandbox.** Finalization therefore stops the build +source, waits for it to be stopped, reserves the snapshot's unique name in the build row, submits +the capture, and then reconciles that name until the snapshot is `active`. Accepting a capture is +not producing one: a later delivery that finds a reservation only ever reconciles it, so a retry +cannot produce a second snapshot. The reservation carries a fixed deadline, which no retry extends. + +**A capture preserves the container's configuration.** Nothing secret may ride a build sandbox's +create request, so the source is created dormant and the build is launched afterwards, over the +sandbox's own stdin, once its id is recorded. Sessions created from the resulting image set every +boot marker explicitly rather than relying on absence. + +### Admission + +`daytona_prebuilds_enabled` (Terraform) / `DAYTONA_PREBUILDS_ENABLED` (env) admits **new** Daytona +build work. Default off. It gates exactly two things: + +- starting a build — manual rebuild, save hook, and the reconciliation cron alike +- selecting a prebuilt image for a fresh session + +Everything else keeps working while it is closed: build callbacks are accepted, finalization runs, +status is readable, and sources and snapshots are reclaimed. Closing admission is therefore the +rollback control, and it never strands a provider resource. Per-scope toggles are unaffected — they +record what an operator wants, and admission decides whether the deployment acts on it. + +### Verification gates + +Nothing here is a claim that Daytona prebuilds are proven against a live deployment. These gates +must pass against the actual organization, target and container class before an operator opens +admission: + +- **G0 — SDK baseline.** The pinned Daytona SDK builds, verifies and publishes a base snapshot. +- **G1 — Provider support.** The deployment permits stopped-container capture, snapshot activation + and deletion, process-session stdin, and the hard TTL a build source needs. +- **G2 — Startup and secrets.** A dormant source composes nothing before it is launched; the launch + context reaches the build through process memory only — not container metadata, provider logs, or + any captureable file; a consumer session runs one ordinary runtime with fresh credentials. +- **G3 — Durable lifecycle.** Acceptance never publishes ready; uncertain creates and captures + retain their identity; retries never recreate; late resources and asynchronous deletion keep their + cleanup handles. +- **G4 — Both scopes.** A repository build and a multi-repository environment build each produce an + artifact that launches two independent sessions after the source is deleted. +- **G5 — Session safety.** Base fallback, partial-create compensation, inactive-snapshot handling, + and stop/resume of a prebuilt session all behave. +- **G6 — Operations.** Permissions, paused-state UI, admission across every trigger path, both web + platforms, historical-provider cleanup credentials, migrations and jobs on both hosts. +- **G7 — Practical benefit.** Measured startup improvement, and capture/storage/concurrency budgets + that hold under the expected backlog. + +### Credential trust boundary + +Open-Inspect's own plumbing never persists build credentials into an image: the callback token stays +in memory, the clone token and scope secrets reach only the build process and its children, and none +of them are written to the container's configuration or to a file. Boat is the one provider where +the build's env takes a file on the way in: a Boat snapshot inherits a sandbox's create-time env, so +the env is written under `/tmp` only after the sandbox is private and bound, the launcher reads it +once and removes it, and `/tmp` is never part of a Boat snapshot, so it reaches no image either (see +[Boat prebuilds](#boat-prebuilds)). + +A repository's `.openinspect/setup.sh` is trusted code that runs with those secrets in its +environment. It can write them into a dependency, a cache, or any file it likes, and the capture has +no way to detect or strip them. Treat a scope's prebuilt image as no less sensitive than the scope's +own secrets. See [Secrets Management](SECRETS.md#secrets-and-prebuilt-images). + +### Runbook + +| Symptom | What it means | What to do | +| ----------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Callback accepted, build stays `building` | Finalization has not settled the capture yet. | Check for `image_build.finalize` job retries. A pending capture republishes itself with a fresh delivery budget; the reservation's fixed deadline ends it either way. | +| Snapshot lookup 404s after capture | The snapshot record is not published yet. | Nothing. The reserved name is polled until it appears, the deadline passes, or the build fails — no second capture is submitted. After a failure the reference is kept, and a 404 only retires it once the source's hard lifetime has certainly elapsed. | +| Snapshot `error` / `build_failed` | The capture failed provider-side. | The build fails with a sanitized reason and keeps its operation reference; maintenance reclaims whatever the name produced. | +| Snapshot `inactive` at spawn | Cold storage, not corruption. | The spawn activates it and waits briefly. If the budget runs out first, that spawn fails transiently **without** retiring the image, and the next one uses it once the activation lands. | +| Snapshot `removing` | Deletion is in progress. | The obligation stays pending; the next maintenance pass confirms it is gone. | +| Build source lifetime exhausted | The source's hard TTL ended before finalization. | The build fails. Check whether the scope's build timeout plus the finalization reserve fits the account's TTL ceiling. | +| `image_build.source_intent_unresolved` | A create whose response was lost may have left a sandbox. | Maintenance looks it up by its reserved name each tick. It is only written off once the source's hard lifetime has certainly elapsed. | +| `image_build.operation_unresolved` older than 6 hours | A reserved capture has not settled and is not being reclaimed. | Investigate provider-side. Verify ownership labels before deleting anything by hand: a resource is this build's only when its labels (or a snapshot's `sourceSandboxId`) say so. | +| Cleanup backlog after a provider switch | Rows dispatch cleanup from their own recorded provider. | Keep `daytona_api_key` and `daytona_api_url` configured until the backlog drains. The base snapshot is not needed for cleanup. | + +--- + +## Boat prebuilds + +On Boat a prebuilt image is a **saved snapshot**: a standalone copy of the build sandbox's disk, +saved under a name derived from the build (`oi-img--`). A session deploys from it +exactly as it deploys from the deployment's base snapshot, with its own env, and the runtime syncs +the checkout and skips `setup.sh`. + +Finalization archives the build sandbox (the same stop an idle session gets), saves the archived +disk under the image name, and waits for Boat to report the snapshot `ready`; the build sandbox is +then deleted. The name is fixed per build, and Boat replaces rather than duplicates a snapshot saved +under an existing name, so a retried finalization can never leave a second artifact behind. +Superseded images are deleted by the same reaper as on every other provider. + +Things to know: + +- **Allowance.** Boat includes a fixed number of saved snapshots per account or organization wallet + and bills each one beyond that monthly. The deployed and retained rollback bases count, as does + every ready image plus, briefly, the image a rebuild is replacing. After switching the Worker, + Terraform [prunes superseded bases](./BOAT_SANDBOX_PROVIDER.md#base-snapshot-pruning), retaining + the deployed base and its confirmed deployment predecessor, not the newest artifact. Cleanup skips + when history cannot establish a predecessor and leaves `saving` snapshots, manual names and + prebuilt images untouched. The `oi-img` namespace is reserved and rejected as a base prefix. + Deleting a source base does not affect existing sandboxes or prebuilt images. A save past the + allowance with no credit fails the build with Boat's `credit_required` reason. Operators can + remove unrelated unused manual snapshots or add credit, then rebuild. +- **What the image keeps.** A saved snapshot follows Boat's snapshot rules: `/home` (the workspace + with every clone and everything `setup.sh` put there) and changes under `/etc`, `/usr`, `/opt`, + `/var`, `/root` and `/srv` are saved; `/tmp` and `~/.cache` are not. A setup script that warms a + cache under `~/.cache` (pip, uv, Playwright browsers) does not carry it into the image — point + such caches somewhere under the workspace instead. OpenCode's refreshed + [model catalog](#model-catalog) lives under `~/.cache` too, so sessions from a Boat image resolve + models from the pinned binary's catalog until OpenCode's own background refresh lands. +- **Build state stays out of the image.** The Boat launcher keeps an image build's lock, log and + runtime state under `/tmp`, so the saved disk carries the workspace and nothing of the build that + produced it; a session's first boot from the image creates its own state under + `/home/user/.openinspect`. +- **The build's env never reaches the image.** Boat keeps a sandbox's create-time env as the default + env for a deploy from a snapshot saved from it, so a build sandbox is created with the boot + markers alone. Its env — the repository set, the clone token, the scope's secrets, the callback + contract — is written to a file under `/tmp` once the sandbox is private and bound to the build; + the launcher reads the file once and removes it, and `/tmp` is never part of a snapshot. A session + deployed from the image always gets its own env. Treat a scope's image as no less sensitive than + the scope's secrets all the same: its filesystem holds whatever `setup.sh` wrote, as the + [credential trust boundary](#credential-trust-boundary) already requires. +- **Organization sandboxes.** A build sandbox is created private, exactly as a session sandbox is: + Boat sets the visibility before the sandbox has a machine, so nothing of the build ever reaches a + shared sandbox ([Boat provider guide](./BOAT_SANDBOX_PROVIDER.md#billing-to-an-organization)). +- **Build machine.** The build runs on the machine size the scope's CPU and memory settings select, + the same size its sessions get, so `setup.sh` has at build time the memory it will have at + runtime. +- **A create whose response is lost** is repeated under the same idempotency key, and Boat answers + with the first sandbox. Should every attempt lose its response, the sandbox holds nothing of the + build (its env never arrived), stops by itself at its TTL, and Boat deletes its data 14 days + later. The create names the sandbox, but nothing looks a sandbox up by that name to reclaim it + sooner. +- **Starts.** Each build spends one Boat sandbox start against the plan's start limits; the save and + the deploys from the image spend none beyond each session's own. + +| Symptom | What it means | What to do | +| ------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Build fails with `credit_required` | The account is at its saved-snapshot allowance with no credit. | Delete saved snapshots nothing deploys from (`DELETE /saved-snapshots/{name}` or the Boat CLI), or add credit, then rebuild. | +| Callback accepted, build stays `building` for minutes | The save is still `saving`, or the source is still archiving. | Nothing; finalization re-polls the reserved name. It fails the build only past the save's fixed deadline, and then reclaims the name. | +| `image_build.operation_unresolved` naming an `oi-img-*` | A reserved save never settled and maintenance keeps asking. | Check the name in the Boat dashboard; a `failed` or `ready` snapshot under it is deleted by maintenance on its next pass. | +| Cleanup backlog after a provider switch | Rows dispatch cleanup from their own recorded provider. | Keep `boat_api_key` (with `boat_api_url` and `boat_org`) configured until the backlog drains. The base snapshot is not needed for cleanup. | + +--- + ## Optimizing Your Setup Script The more work you front-load into your `.openinspect/setup.sh`, the faster your sessions start. Here @@ -217,8 +425,10 @@ are some tips: - **Install all dependencies** — `npm install`, `pip install -r requirements.txt`, `bundle install`, etc. - **Run build steps** — `npm run build`, `cargo build`, code generation, compiled assets -- **Warm caches** — Running your test suite once during setup means cached files are available for - subsequent runs in the session +- **Warm caches** — Run tools that persist reusable caches. For example, `tsc --noEmit` only writes + a reusable `.tsbuildinfo` cache when the project enables `incremental`. Since `setup.sh` also runs + on fresh session boots, guard heavy cache-warming steps with `OPENINSPECT_BOOT_MODE` so they run + during image builds (`build`) rather than delaying fresh boots (`fresh`). - **Pre-download large resources** — Models, datasets, or any large files the agent might need Don't worry about build duration. Builds run in the background and users always get the last @@ -272,9 +482,11 @@ Updating the shared sandbox toolchain does not automatically refresh prepared re environment images. Use the refresh button below after a dependency-only update when those images must pick up the new tools. Existing compatible saved sessions remain usable. -Pre-built images are rebuilt every 30 minutes when new commits are detected. If you just pushed code -and want the image updated immediately, trigger a manual rebuild — the refresh button next to the -repository in Settings > Images, or next to the environment in Settings > Environments. +Pre-built images are rebuilt every 30 minutes when new commits are detected. The image's +[model catalog](#model-catalog) is as old as the image, so a newly published model reaches a quiet +repository only on its next rebuild. If you just pushed code, or want a new model available +immediately, trigger a manual rebuild — the refresh button next to the repository in Settings > +Images, or next to the environment in Settings > Environments. --- diff --git a/docs/MEMORY.md b/docs/MEMORY.md new file mode 100644 index 0000000000..d446f5cef4 --- /dev/null +++ b/docs/MEMORY.md @@ -0,0 +1,300 @@ +# Persistent session memory + +Memory carries useful knowledge between sessions without changing repository files. It has no +embedding store or semantic search: the agent sees a compact fact catalog, discovers additional +facts with lexical `memory_search`, and reads relevant bodies with `memory_read`. + +Every session receives memory workflow guidance, even with an empty catalog. Before finishing a +task, agents are prompted to consider saving verified discoveries such as build/test/setup gotchas, +corrected assumptions, or difficult failure diagnoses, checking for duplicates first. Routine +progress and guesses should not become memories. A `memory_write` result of `proposed` awaits human +review in Settings → Memories or Settings → Shared memories; it is not yet active memory. + +## User experience + +| Surface | Behavior | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Settings → Memories | Manage your personal facts and directives, review proposals, edit, archive, restore, supersede, revert to an earlier revision and inspect history. | +| Settings → Shared memories | Select a repository or environment. Readers see its catalog; authorized maintainers can manage it. | +| Personal default | **Include my personal memories in new sessions** is initially enabled. It applies to web, integration-created and scheduled sessions, and is available to every session creator. | +| Session sidebar → Memories | Pinned memories grouped by repository, environment or personal scope; hover or focus a row for its revision, inclusion and estimate. Flags omitted records and subsequent edits/archives. | + +Personal memories can be included in **shared sessions**. Included content may appear in responses +and be visible to collaborators. This does not give collaborators access to the owner's personal +settings catalog. Opting out excludes personal context and also denies personal reads and writes +through memory tools. Changing the default affects **new sessions only**; it cannot erase text +already supplied to an agent or included in a conversation. + +### Facts, directives and approval + +| Record | How it loads | Agent-write policy | +| -------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Personal fact | Title and description in the catalog; body read on demand | Active immediately only in a root session that has remained private and owner-only. Otherwise proposed for owner approval. | +| Personal directive | Full content | Proposed for owner approval. | +| Repository/environment fact | Catalog; body on demand | Proposed for maintainer approval. | +| Repository/environment directive | Full content | Proposed for maintainer approval. | + +Human-authored records are active immediately. Editing keeps the original record provenance and adds +a revision with the editor's identity. Rejected proposals are archived; restoring them returns them +to proposed status. Restoring an approved record returns it to active status. A replacement proposal +does **not** archive its predecessor until approval. Concurrent edits and decisions reject stale +revisions with HTTP 409. + +The management pages can select records on the current page and approve, reject, archive or restore +them together. Each selected record is still fenced by the revision shown and authorized against its +own scope, so a record that changed or that the caller cannot manage fails alone while the rest +apply. Each request is bounded to stay within D1's per-invocation query limit, so the page sends +larger selections as sequential requests. Archiving is the bulk equivalent of deleting. + +Sandbox tools currently authenticate a session, not an immutable prompt author. For this reason, +automatic personal facts are disabled permanently once a session is shared or gains a collaborator, +even if it is later made private again. Children never auto-save personal facts. A child created by +a different participant cannot write to the inherited owner's personal scope. + +## Architecture and implementation + +| Layer | Where | Responsibility | +| ---------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shared contracts | `packages/shared/src/types/memories.ts`, `memory-tools.ts` | Wire schemas (management DTOs, selection summaries, sandbox requests/responses), limits, scope helpers, the lifecycle transition table and the agent tool definitions. `memories.manage_own`. | +| Domain | `control-plane/src/memory/` | Partitions (identity only; records carry a separate display `scope`), selection and budget, rendering, initial status, DTO projection, `SessionMemorySelector` for new sessions and `SessionMemoryService` for agent operations. Services receive their stores through constructors; `*-factory.ts` modules and routes wire D1. | +| Access policy | `control-plane/src/authorization/memory-access.ts` | `MemoryManagementPolicy` for humans and `SessionMemoryAccessPolicy` for session principals, both returning typed decisions with stores injected; `memory-access-factory.ts` wires D1. | +| Stores | `control-plane/src/db/memory-records.ts`, `session-memory-selections.ts`, … | SQL only: revisioned records and lifecycle, pinned selections, preferences, lexical fact search and the commit-time agent write guard. Queries are built with the `sql` fragment template. | +| D1 | Migration `0084_memories.sql` | Records keyed by typed identity columns (`owner_user_id` — a foreign key to `users` — `repo_id` or `environment_id`, exactly one per `partition_type`) with a generated, indexed `partition_key`; repository names are display-only. Immutable revisions, per-user default, session manifest headers and ordered revision references. | +| Session creation | `routes/session-create.ts`, scheduler, child spawn | Select and pin the session's memories in the session insert's transaction (`Pinned`: resolved for roots, inherited by children). Schedulers use the execution owner's default. | +| Runtime boot | `sandbox-runtime/src/sandbox_runtime/memories.py` | Fetch the rendered memory with the session-bound token, clear stale restored content, then atomically write owner-readable `oi-memory.md` in the harness configuration directory. | +| Harness tools | `tools/_memory.js`, `harness/memory_tools.py` | Both harnesses build `memory_read`, `memory_search` and `memory_write` from the generated specs and forward arguments verbatim. OpenCode reads the file via `instructions`; Claude appends it. | +| Web | `web/src/hooks/use-memories.ts`, `components/settings/memories-settings/` | Typed queries and mutations (revision fencing via `If-Match`), owner/shared management pages and session diagnostics. | + +### Extending memory + +- **A new scope** (for example, team): add it to `MEMORY_SCOPE_TYPES` and `memoryScopeSchema`, then + follow the compiler — every switch over scopes and partitions is exhaustive (`partition.ts`, + `sources.ts`, `memory-access.ts`, the write guard, the shared scope helpers and the web settings + link). Storage needs one typed identity column (for example, `team_id` with its foreign key), + added to the `partition_key` expression and the check constraint; the indexes are unchanged. +- **A new lifecycle action or state:** add an entry to `MEMORY_TRANSITIONS`; the store, routes, DTO + capabilities and web action buttons all derive from it. +- **A new agent tool:** add it to `MEMORY_TOOLS` in `packages/shared/src/memory-tools.ts`, run + `npm run generate:memory-contract -w @open-inspect/shared`, and add a four-line OpenCode wrapper + in `tools/`. The Claude harness picks it up from the generated JSON. A shared test fails when the + generated artifacts are stale. +- **Selection semantics:** bump `MEMORY_SELECTION_VERSION`. It is provenance only; loaders never + branch on it, and rendering always uses the current format with its own hard limit, so existing + sessions keep booting. + +No project scope, embeddings, automatic memory search, repository writes or new infrastructure +services are required. Memory estimates are available on the session manifest; a broader +per-component context-snapshot feature is not introduced here. + +### Pinning and live reads + +Memory boot requires runtime v74 or later for either harness. The runtime/rebuild floors reject +pre-memory repository images. Restores remove old context and abandoned staging files before +fetching; new content is installed through a unique exclusive 0600 file and atomic replacement. + +- Directives and catalog entries use **pinned revisions** for the session's lifetime, including + sandbox restarts and restores. A child copies the same selection and personal owner, not the + spawning participant's personal catalog. +- `memory_read` returns the **current** fact body and provenance. A pinned archived record returns + only its archive notice (`archiveKind`, `archivedAt`, `archiveNote`). Proposals, unpinned + archives, and directives cannot be expanded by sandbox tools. +- A top-level session may directly read an active fact in its authorized scopes even if budget + limits omitted it. An inherited child cannot expand into unpinned personal records. +- Editing or archiving does not rewrite an existing session's injected text. New sessions resolve + from current active records. +- The selection hash covers the ordered record/revision/inclusion tuples and the personal selection + flag. User aliases can be merged without changing the pinned selection hash. + +### Limits and ordering + +| Limit | Value | +| ----------------------------------- | -------------------------------------------------------: | +| Title | 200 characters | +| Description | 10–420 characters | +| Directive body | 2,000 characters | +| Fact body | 20,000 characters | +| Directives per scope / overall | 6,000 / 12,000 body characters; at most 100 directives | +| Fact catalog | 24,000 title+description characters, at most 200 entries | +| Rendered boot context | 240,000 characters including labels/escaping | +| Management page | 50 records by default, at most 100 | +| Accepted agent writes per session | 20, including subsequently archived records | +| Pending agent proposals per session | 5 | + +Scope priority is environment, repositories in session order, then personal. Directives are oldest +first; facts are most recently updated first; IDs break timestamp ties. Records beyond a budget are +omitted whole and counted in aggregate; only selected items are persisted. Candidate queries are +bounded per scope/type and never fetch fact bodies. Token estimates use rendered text length / 4, +including labels and framing; they are estimates, not provider-measured token counts. + +## API + +All human memory responses are private/non-cacheable. Identity comes from authentication, never a +request body. Repository management uses existing repository permissions and team grants; +environment management uses existing ownership/management authorization. Personal management is +owner-only, including when the caller is another administrator. + +| Method and path | Purpose | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /memories?scope=...&status=...&offset=...&limit=...` | Page through one scope (`personal`, `repository`, or `environment`) and status (`active`, `proposed`, or `archived`). Repository scope also takes `repoOwner` and `repoName`; environment takes `environmentId`. `nextOffset` is null at the last page. | +| `POST /memories` | Create a memory, optionally superseding an active one via `supersedesMemoryId`; the superseded record lists it in `supersededByMemoryIds`. | +| `GET /memories/:id` | Current record and server-calculated management capabilities. | +| `PATCH /memories/:id` | Revise content; `If-Match: ` is required (428 when missing, 409 when stale). | +| `GET /memories/:id/revisions` | Immutable revision history. | +| `POST /memories/:id/{action}` | `approve`, `reject`, `archive`, or `restore` with `If-Match`; only `archive` and `reject` accept an optional `archiveNote`. Transitions follow `MEMORY_TRANSITIONS`. | +| `POST /memories/bulk` | Apply one `action` to up to `MEMORY_BULK_ACTION_MAX_RECORDS` records, each named with the `revisionId` it was reviewed at. Records are authorized and decided independently, in order; the response lists each record's outcome. | +| `POST /memories/preview` | Summarize the selection a new session would pin (items, sizes, omissions), without creating it. Pass `teamId` to preview a team session; the caller must be a member of the active team. | +| `GET, PUT /memory-preferences` | Read/save the current user's personal inclusion default. | +| `GET /sessions/:id/memories` | The pinned selection summary with per-item drift, readable with the session. | +| `GET /sessions/:id/sandbox-memory` | The session's rendered memory (boot context), sandbox-bound. | +| `GET /sessions/:id/sandbox-memory/:memoryId` | Sandbox-bound live read. | +| `POST /sessions/:id/sandbox-memory` | Sandbox-bound agent write with scope/approval/quota checks. | +| `POST /sessions/:id/sandbox-memory/search` | Session-authorized lexical discovery of active current facts, including records outside the boot catalog. | + +Sandbox routes reject credentials belonging to another session and recheck current workspace/team +repository grants and environment ownership on every boot render, read, search and write. Agent +inserts additionally enforce, atomically, the facts about the writing session: an active owner, a +live (`created`/`active`) session that still reaches the target partition, personal auto-save +eligibility, quotas and the replacement predecessor. Settled sessions must be reactivated by a fresh +prompt before writing. Grant rules are deliberately not duplicated in that SQL guard: a grant +revoked in the milliseconds between the route check and the insert can leave a shared-scope +_proposal_, which every later read denies and a human must approve. Repository memories are keyed by +the stable repository ID, so a reused name never inherits them and a renamed repository keeps them. +Record-level management (read, edit, history, lifecycle actions) authorizes the stored partition +identity directly and never resolves a record through its display names. Browser responses for +previews and session status carry a selection summary only, never the personal owner or hash. + +New sessions never fail because of memory: `SessionMemorySelector` omits repositories or an +environment that the session principal cannot read from the selection, and session admission remains +`authorizeSessionTarget`'s job. Audits record record/revision/status/actor/session IDs, never memory +content or private archive-note text. Scope identifiers are retained after target deletion to +preserve historical manifests. There is no hard-delete endpoint. Restoring an approved memory is +allowed only when its entire replacement family has no active record. + +### Agent write destinations + +`memory_write` uses session-relative scopes. The control plane derives the personal owner, the sole +repository, or the associated environment from the authenticated session: + +```javascript +memory_write({ + scopeType: "repository", // Or "personal" / "environment" + memoryType: "fact", + title: "Integration test setup", + description: "Database preparation required before the integration suite.", + content: "Start Postgres and apply test migrations before running integration tests.", +}); +``` + +For a multi-repository session, add **both** `repoOwner` and `repoName` to select a member +repository. This applies to ad-hoc sets and environment-backed sessions alike. Omitting the selector +returns HTTP 400 with the available repository names; the server never silently defaults to the +primary repository. An explicit non-member, absent environment/repository, or repository without a +stable ID is denied. Sandbox writes do not accept `environmentId`; environment identity is always +derived from the session. Human management APIs still require explicit repository/environment +identities. + +Both harnesses forward the tool arguments unchanged to `POST /sessions/:id/sandbox-memory`; the +endpoint's request schema is the tool input schema (`sandboxMemoryWriteSchema`), so `scopeType` is +`"repository"`, `"environment"`, or `"personal"` and an explicit selector is the top-level +`repoOwner`/`repoName` pair. The server resolves a complete scope before performing the existing +current-access and commit-time checks. Inference does not change approval, opt-out, quotas, +replacement rules, or pinned context. + +## Local verification + +### Searching beyond the catalog + +Both harnesses expose `memory_search`. It discovers **current active facts**, not directives, +proposals, archives, or historical revision text. Search results contain IDs, revision IDs, scope +labels, titles and descriptions; call `memory_read` for the body. + +```javascript +memory_search({ query: "billing webhook deduplication", scopeType: "repository", limit: 10 }); +``` + +Omit `scopeType` to search permitted session scopes. Repository scope searches all attached +repositories; optionally supply both `repoOwner` and `repoName` to select one. Personal owner and +environment identity are derived from the session. Personal opt-out excludes personal results, and +inherited children can discover only their pinned personal memory IDs. Selected shared scopes are +authorized before the query and checked again before returning results. + +Queries are literal whitespace-separated keywords: every term must occur in the current title, +description or body. Matching uses SQLite's ASCII case folding; it provides no stemming, synonyms, +semantic similarity or wildcard/query-language syntax. SQL wildcard characters are escaped. Per-term +title matches score 5, description matches 3, and body matches 1; the strongest field match for each +term is summed. Updated time and memory ID break ties. SQL matches and ranks before limiting, across +the full applicable fact store rather than the latest 200 records. + +Queries are 2–256 characters with at most eight distinct terms. Results default to 10, max out at +20, and fit a 24,000-character serialized response budget. `hasMore` signals that the agent should +refine its query. There is no pagination or unrestricted browsing endpoint. Empty memory context +does not remove the search tool, and searching does not alter the session's pinned context. + +Search sits behind the `FactSearchIndex` port (`memory/fact-search.ts`): the session service builds +an engine-independent `FactQuery` from authorized partitions and shapes the bounded response, and +`LexicalFactIndex` answers it with one escaped-`LIKE` statement ranked in SQL by +`FACT_SEARCH_WEIGHTS` over `MEMORY_SEARCH_FIELDS` (`memory/search-fields.ts`). A different engine +(Postgres full-text search, embeddings) is another adapter; no migration or new service is needed +today. Body matching still scans text; bounded results do not guarantee constant query cost. The +opt-in Node SQLite benchmark records query plans and rare/broad-query timings at 1,000 and 10,000 +facts with representative and maximum-size bodies: + +```bash +MEMORY_SEARCH_BENCHMARK=1 MEMORY_SEARCH_BENCHMARK_OUTPUT=/tmp/memory-search-benchmark.json \ + npm test -w @open-inspect/control-plane -- \ + src/db/lexical-fact-index.test.ts --maxWorkers=1 +``` + +These local measurements are not deployed D1 latency or provider-canary proof. Indexed lexical +search and semantic retrieval remain separate follow-ups if corpus size or measured cost demands +them. + +### Test commands + +Use Node 24; build shared before dependent TypeScript checks. Run heavyweight checks sequentially. +No Cloudflare, Modal or model-provider credentials are needed for these tests. + +```bash +npm ci +npm run build -w @open-inspect/shared +npm test -w @open-inspect/shared -- --maxWorkers=1 +npm test -w @open-inspect/control-plane -- --maxWorkers=1 +npm run test:integration -w @open-inspect/control-plane -- \ + test/integration/memories.test.ts \ + test/integration/memories-routes.test.ts \ + test/integration/memories-access.test.ts \ + test/integration/memories-search.test.ts --maxWorkers=1 +npm test -w @open-inspect/web -- --maxWorkers=1 +npm run typecheck -w @open-inspect/control-plane -w @open-inspect/web +npm run lint:sql-portability + +cd packages/sandbox-runtime +uv sync --frozen --extra dev --python 3.12 +# A short disposable temp path avoids macOS Unix-socket path-length failures. +uv run --frozen --extra dev pytest tests --basetemp=/tmp/oi-memory-pytest +OPENCODE_TEST_BINARY=/path/to/opencode-1.18.29 \ + uv run --frozen --extra dev pytest tests/test_opencode_reasoning_contract.py -k memory_text +``` + +The optional binary test uses isolated configuration, synthetic credentials and a localhost fake +provider. It verifies actual OpenCode prompt/tool serialization, not a real model's behavior. + +## Rollout and failure behavior + +1. Apply migration 0084 with the existing D1/Node migration mechanism. +2. Deploy the control plane. Existing sessions without a manifest receive empty memory context; they + are not retroactively resolved. No new Durable Object binding is needed. +3. Rebuild/deploy the sandbox runtime image, then deploy the web app. Both harnesses must use the + updated runtime to gain the tools and boot phase. +4. In a disposable session, create a personal directive and fact, verify the preview and loaded + diagnostics, exercise read/write/approval, then repeat with personal inclusion disabled and with + a child session. Verify restore and archived-read behavior before broad rollout. + +An old control plane returning 404 produces empty memory, clearing stale files. Transient fetch +errors retry a bounded number of times. Unauthorized, malformed or exhausted fetches fail the +`memory` boot phase before the harness starts. A failed database transaction creates neither orphan +revisions nor successful domain audit records. Rollback application code without dropping the memory +tables; already-injected text cannot be revoked from a running conversation. + +Local tests are not evidence of deployed Cloudflare or Modal behavior. Deployment/provider canaries +remain a separate rollout gate. diff --git a/docs/MODAL_DOCKER.md b/docs/MODAL_DOCKER.md new file mode 100644 index 0000000000..0352c29ebe --- /dev/null +++ b/docs/MODAL_DOCKER.md @@ -0,0 +1,158 @@ +# Modal VM backend + +Open Inspect offers two Modal compute backends: + +| `SANDBOX_PROVIDER` | Runtime | User Docker workloads | +| ------------------ | ----------------------- | --------------------- | +| `modal` (default) | Existing gVisor sandbox | Not enabled | +| `modal-vm` | Modal VM | Included | + +Selection is deployment-wide. There is no Docker checkbox or repository/session override. Mixed use +within one deployment is a follow-up. Both backends share a Modal app, account, credentials, +transport, and implementation; they have separate prepared-image pools. + +## Deployment + +For Terraform, set `sandbox_provider = "modal-vm"`. The existing Modal module builds and verifies +the Docker image before deploying the worker. No extra provisioning/admission flag is required. For +a standalone control plane, set `SANDBOX_PROVIDER=modal-vm` and provision the data plane first: + +```bash +cd packages/modal-infra +BUILD_MODAL_VM_IMAGE=true uv run python deploy.py --build-sandbox-image +BUILD_MODAL_VM_IMAGE=true uv run modal deploy deploy.py +``` + +These commands create billable resources. Use the intended Modal environment and credentials. Never +deploy `src/app.py` directly. VM launches reject old or incompatible API responses rather than +falling back to standard sandboxes. Existing standard clients can still use the standard endpoints. + +## Runtime and resources + +The harness, bridge, workspace, IDE, and desktop run directly on the VM host. Docker is for user +workloads such as PostgreSQL, Redis, and container builds. The runtime supervises the local daemon; +user environment variables cannot enable Docker or redirect its readiness probes. + +Generic `cpuCores` and `memoryMib` reserve resources for the **outer VM**, not individual +containers. Missing/null requests select the offering defaults: 0.5 CPU cores and 2048 MiB. The VM +can burst up to `cpuLimitCores` and `memoryLimitMib`; missing/null limits default to 2 CPU cores and +4096 MiB, or the corresponding request if it is larger. Explicit limits must be at least the +effective request. VM image builds use their scope's configured requests and limits. Standard build +sizing is unchanged. These values are product defaults, not claimed Modal minimums. + +Modal bills CPU and memory by `max(request, actual usage)`, not the configured limit. Bursting above +the request depends on spare host capacity; memory pressure can evict a sandbox using more than its +request. CPU is throttled at its limit and exceeding the memory limit can cause an OOM kill. Size +requests using observed usage and monitor exit reasons, especially for Docker and parallel builds. +See [Modal Sandbox resources](https://modal.com/docs/guide/sandbox-resources). + +## Snapshots and recovery + +VM session snapshots are **destructive**: quiesce Docker, capture the filesystem, then confirm VM +termination. Later work restores into a new VM. Standard Modal snapshots remain non-destructive. A +failed or ambiguous capture/retirement must not be reported as a successful checkpoint. + +VMs therefore keep running between turns. They are saved and stopped on inactivity, lifetime expiry, +a lost heartbeat, a runtime failure or archive; a VM that stops heartbeating is captured without its +runtime. Cancelling a session stops its VM without saving. While a save has failed or its result is +unknown, new prompts are held and the session offers to retry the save, restore the last saved +state, or discard the VM and start fresh. + +Filesystem capture is not process/RAM continuity or an application-consistent database backup. +Containers must use appropriate persistence and restart policies. Live Docker pause/resume is not +provided. Raw daemon logs are truncated after clean preparation before reusable image capture. + +Retried VM launches adopt only an exactly owned allocation and recover its original interactive +credentials. A predecessor must be confirmed terminated before launching a replacement. A build +whose create response is lost is marked failed; its VM runs until the provider's build sandbox +timeout (40 minutes by default, up to 70 minutes, including the 10-minute finalization grace). +Re-triggering the build uses a new build ID and allocation name, not the previous allocation. +Returned build handles are persisted for cleanup before backend validation; incompatible builds +never start and cannot publish prepared images. + +Before create or restore returns, the control plane records a pending VM reference of the form +`modal-vm-session:["sessionId","sandboxId"]`. The session id selects the named allocation; the +session and sandbox (generation) ids select its ownership tags at create. The reference resolves +only while that generation's allocation runs. A not-visible reference is confirmed absent only once +its generation is older than the materialization bound: the launch window plus the endpoint +`timeout=` and a margin. Before then, a queued create could still materialize the allocation. An +older launch that materializes later may briefly block a replacement, but the single allocation name +and fenced credentials prevent overlapping work. + +The authenticated `POST /api-resolve-vm-sandbox` endpoint is lookup-only. Its body contains exactly +`{"session_id":"...","sandbox_id":"..."}`; it accepts no launch settings or secrets. It finds the +running allocation by session name, checks the generation's ownership tags, and returns: + +```json +{ + "success": true, + "data": { + "sandbox_id": "generation-id", + "modal_object_id": "sb-real-id", + "code_server_url": null, + "code_server_password": null, + "vnc_url": null, + "vnc_password": null, + "ttyd_url": null, + "tunnel_urls": null, + "sandbox_backend": "modal-vm" + } +} +``` + +New VM allocations record versioned service flags and effective ports in provider-owned launch tags. +Only services enabled by these tags return URLs/passwords; extra tunnels use port-to-URL mappings. +Legacy allocations without these tags (or with unknown/incomplete metadata) resolve only the real +`modal_object_id`, not access credentials or tunnels. Resolve never infers enabled services from +environment variables, which may have contained user secrets on older allocations. Such VMs need a +new launch to recover interactive access. Resolve neither creates nor retires an allocation or +writes tunnel configuration. A stopped VM is not discoverable by name. Resolve does not return a +terminal access token; the control plane mints that token only when it still holds the generation's +sandbox auth token in memory. + +Create, restore, and resolve report typed HTTP 409 error `detail` values: + +- `not_visible`: resolve found no named allocation. +- `other_generation`: the ownership tags do not match. +- `window_closed`: create/restore missed the launch deadline with no owned allocation. +- `race_pending`: create/restore cannot yet see the winner after `AlreadyExistsError`, or resolve + found a VM with none of its tunnel URLs readable yet. Like create/restore, resolve returns a + partial tunnel map rather than waiting for ports Modal did not publish. + +Create reports HTTP 501 `docker_not_available` before retiring or allocating anything when the +deployment has no verified Docker image. The control plane fails that launch as permanent instead of +resolving it. Unexpected provider errors remain 500. The pending-reference stop endpoint retains its +separate `pending_reference_not_visible` response. + +## Switching backends + +Changing `SANDBOX_PROVIDER` is an operator cutover, not session migration. Existing sessions and +snapshots may become unusable. Drain/retire active allocations first when feasible, retain +credentials for pending cleanup, and rebuild images under the selected backend. Do not relabel old +artifacts. Rollback to `modal` does not transparently resume VM sessions or prove old VMs have +stopped. + +After changing `sandbox_provider`, apply Terraform and ensure the web app is deployed with the new +provider. The deployment step depends on `web_platform`: + +- **Cloudflare**: `terraform apply` rebuilds and deploys the web Worker with the new provider. No + separate redeploy step is needed. +- **Vercel**: after the apply, create a new production deployment through your configured CLI, + Git-linked, or GitHub Actions deployment path. If using Actions, manually run **Deploy Web**; + Terraform-only changes do not trigger it, and it skips deployment unless `VERCEL_API_TOKEN` and + `VERCEL_PROJECT_ID` are configured in GitHub. See + [Deploy the Web App](GETTING_STARTED.md#step-7-deploy-the-web-app) for the CLI and Git-linked + paths. + +Terraform updates `NEXT_PUBLIC_SANDBOX_PROVIDER`, but Vercel environment changes apply only to new +deployments and `NEXT_PUBLIC_*` values are fixed at build time. Until the new Vercel deployment is +live, `GET /api/image-builds` still filters by the old provider, so the Pre-Built Images page shows +the old provider's builds. This applies to any provider switch, not only switches to or from +`modal-vm`. + +PR #2007's earlier per-session Docker/variant design was not deployed. Its schema additions and +settings are not part of this implementation, so no variant migration is required. + +Provider-backed canaries must validate Docker startup, build/restore, access after adoption, +snapshot/retirement, and cleanup in the target deployment before production rollout. Unit tests +alone do not prove those provider behaviors. diff --git a/docs/MULTI_REPO_AUTOMATIONS.md b/docs/MULTI_REPO_AUTOMATIONS.md index 70248c41b2..9b3e0d85d7 100644 --- a/docs/MULTI_REPO_AUTOMATIONS.md +++ b/docs/MULTI_REPO_AUTOMATIONS.md @@ -15,16 +15,22 @@ where changes are needed. ```text automation ── repositories (0..10, the live selection) │ - └── invocation one per firing (schedule tick, Trigger Now, or event) + └── invocation one per recorded firing (schedule tick, Trigger Now, or event) │ carries the firing-scoped keys and skip reason └── runs (0..10) one per repository, each linked to one session └── session ordinary sandbox session; owns branch, artifacts, PR ``` -Every firing — single-repo, multi-repo, repo-less, or skipped — takes the same path: it records one -`automation_invocations` row, and unless it was skipped, one `automation_runs` child per repository. -There is no separate single-repo pipeline and no group-of-N special case; a single-repo firing is -simply an invocation with one run. +Recorded firings — single-repo, multi-repo, repo-less, or skipped — take the same path: one +`automation_invocations` row, and unless skipped, one `automation_runs` child per repository. There +is no separate single-repo pipeline and no group-of-N special case; a single-repo firing is simply +an invocation with one run. + +Generic event execution-authorization denials increment the response's `skipped` count without +creating an invocation. GitHub's earlier trigger-repository grant check is an exception: it can +record a sessionless `unauthorized` run with reason `repo_not_granted`, including before an executor +is resolved. Scheduled authorization denials instead record a skipped invocation and pause the +automation; see [Automations](AUTOMATIONS.md#managing-automations). **API ↔ UI vocabulary.** The API speaks `automation / repository / invocation / run / session`. The UI keeps its established "run" copy: the history section is still titled "Run History", the empty @@ -221,9 +227,10 @@ invocation aggregates links for display only; there is no cross-repository "mega ## API surface -- `GET /automations/:id/invocations` — the history endpoint: one entry per firing +- `GET /automations/:id/invocations` — the history endpoint: one entry per recorded invocation (`{invocations, total}`), each carrying its child `runs` with repository snapshots. `total` counts - invocations. + invocations. Generic event execution-authorization denials are not recorded; GitHub pre-admission + grant denials can appear as `unauthorized` instead. - `POST /automations/:id/trigger` — returns `201 {invocationId, runs}`; `409` when blocked by an active invocation. - Repository selection is written via `repositories: [{repoOwner, repoName, baseBranch?}]` on diff --git a/docs/OPENAI_MODELS.md b/docs/OPENAI_MODELS.md index 54e3007262..849471b8c3 100644 --- a/docs/OPENAI_MODELS.md +++ b/docs/OPENAI_MODELS.md @@ -13,6 +13,10 @@ can use the installation default, select a specific account, or explicitly use A See [Available Models — OpenAI](AVAILABLE_MODELS.md#openai) for supported model IDs, reasoning effort options, and defaults. +Existing sessions and saved bot settings selecting `gpt-5.3-codex` or `gpt-5.3-codex-spark` resolve +to `openai/gpt-6-sol` when used. The retired models are no longer offered in the picker; update +saved defaults and automations to a model available to your account. + --- ## Setup diff --git a/docs/OPENCODE_LOCAL.md b/docs/OPENCODE_LOCAL.md new file mode 100644 index 0000000000..1afc68e4c4 --- /dev/null +++ b/docs/OPENCODE_LOCAL.md @@ -0,0 +1,140 @@ +# Running OpenCode Locally on This Repo + +[OpenCode](https://opencode.ai) is the built-in harness Open-Inspect runs inside sandboxes, but it +is also a normal CLI you can run on a checkout of this repository to work on it. This page covers +that local use: installing a matching version, pointing it at the repo, and the ways a local run +differs from a sandbox session. + +For the harness as a product feature (choosing a harness for a session, provider accounts, +subscriptions), see [CLAUDE_AGENT.md](CLAUDE_AGENT.md) and +[HOW_IT_WORKS.md](HOW_IT_WORKS.md#the-agent). + +--- + +## Install + +Use Node `>=24.0.0`, the same floor as the repo (`package.json` `engines`). + +```bash +npm install -g opencode-ai # or: curl -fsSL https://opencode.ai/install | bash +opencode --version +``` + +The sandbox image pins its OpenCode version in +[`packages/sandbox-images/toolchain.json`](../packages/sandbox-images/toolchain.json): + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------------- | +| `opencode` | The version baked into sandbox images — match this to reproduce a session | +| `opencodeMinimum` | The floor `validate_toolchain` enforces at image-build time (`bundle.py`) | + +Match the pinned version when you are debugging harness behaviour rather than writing ordinary code: + +```bash +opencode upgrade 1.18.29 # use the value from toolchain.json +``` + +## First run + +```bash +cd background-agents +opencode # TUI in the repo root +opencode run "summarise the D1 schema" # headless, prints to stdout +opencode run --agent plan "how do sessions resume after a snapshot restore?" +``` + +Credentials live outside the repo in `~/.local/share/opencode/auth.json`: + +```bash +opencode auth list +opencode auth login # pick a provider, paste a key or complete OAuth +``` + +OpenCode Zen's free models answer with no credentials configured at all, so `opencode run` works on +a fresh machine — it just picks a free model rather than the one you want. For real work on this +repo, log in and select a model explicitly: + +```bash +opencode run -m anthropic/claude-sonnet-4-6 "..." # the catalog default in packages/shared/src/models.ts +opencode models # everything your credentials can reach +``` + +### OpenCode Go + +[OpenCode Go](https://opencode.ai/docs/go/) is a flat-rate subscription on top of an OpenCode Zen +key. Subscribe at `https://opencode.ai/auth`, then either paste the key into the TUI with `/connect` +(choose **OpenCode Go**) or export it — one variable covers both gateways, because the `opencode` +and `opencode-go` providers share the `OPENCODE_API_KEY` env var: + +```bash +export OPENCODE_API_KEY=... +opencode run -m opencode-go/kimi-k3 "..." +``` + +Sandbox sessions use the same key, supplied as a secret rather than an env var — see +[AVAILABLE_MODELS.md](AVAILABLE_MODELS.md#opencode-go). + +## What OpenCode picks up from the repo + +- **`AGENTS.md`** at the repo root is loaded automatically. `CLAUDE.md` is a symlink to it, so both + harnesses read one file — edit `AGENTS.md`. +- **No committed `.opencode/` or `opencode.json`.** There is nothing repo-specific to load, and + nothing to keep in sync. +- If you add local config (`opencode.json`, `.opencode/`), note that neither path is in `.gitignore` + — the sandbox keeps its generated `.opencode/` out of `git status` with a runtime git exclude, not + with `.gitignore`. For a local-only config, do the same: + + ```bash + echo ".opencode/" >> .git/info/exclude + ``` + +## The verification loop + +OpenCode has no special access here — point it at the same commands `AGENTS.md` documents, and build +`@open-inspect/shared` first whenever shared types change: + +```bash +npm run build -w @open-inspect/shared +npm run typecheck +npm run lint:fix +npm test -w @open-inspect/control-plane +``` + +## Working on the OpenCode harness itself + +The harness code lives in `packages/sandbox-runtime/src/sandbox_runtime/` (`opencode_server.py` +launches the server; `harness/opencode*.py` drive it over HTTP/SSE). Its tests run with `uv` from +the package directory: + +```bash +cd packages/sandbox-runtime +uv run --frozen --extra dev pytest tests/ -v +uv run --frozen --extra dev pytest tests/test_opencode_client.py -q +``` + +## How a local run differs from a sandbox session + +Same binary, different configuration. The sandbox setup is assembled in `opencode_server.py`; a +local run uses your own files and defaults instead. + +| Aspect | Local run | Sandbox session | +| ----------- | --------------------------------------- | -------------------------------------------------------------------------------- | +| Config | `opencode.json` / `~/.config/opencode/` | Generated and passed as `OPENCODE_CONFIG_CONTENT` | +| Permissions | OpenCode's defaults; `--auto` is opt-in | `{"*": {"*": "allow"}}` — the sandbox is the containment boundary | +| Interaction | TUI can ask you questions | `OPENCODE_CLIENT=serve` disables the question tool (nothing can answer) | +| Tools | Whatever you install | `.opencode/tool/*.js` (PR creation, child sessions, Slack notify) staged at boot | +| Skills | Whatever you install | Bundled skills copied into `.opencode/skills/` | +| MCP | Your own config | Injected from session config, packages installed at boot | +| Model | `-m` or your default | Chosen per session by the control plane | +| Working dir | Wherever you start it | The repo for single-repo sessions, `/workspace` for multi-repo | + +A consequence worth remembering: a prompt that works locally in the TUI can hang in a session if it +relies on the agent asking a clarifying question. That path is deliberately closed in the sandbox. + +## Other entry points + +```bash +opencode serve --port 4096 # headless server (what the sandbox supervisor runs) +opencode attach # attach a TUI to a running server +opencode pr # check out a GitHub PR branch, then start +``` diff --git a/docs/OPENCOMPUTER_PROVIDER.md b/docs/OPENCOMPUTER_PROVIDER.md deleted file mode 100644 index 09fa7019bf..0000000000 --- a/docs/OPENCOMPUTER_PROVIDER.md +++ /dev/null @@ -1,168 +0,0 @@ -# OpenComputer Sandbox Provider - -Open-Inspect can use OpenComputer as the sandbox provider for coding sessions. The control plane -talks directly to the OpenComputer REST API from Cloudflare Workers; there is no Modal-style shim -service to deploy. - -Use `sandbox_provider = "opencomputer"` when you want sessions to run in OpenComputer sandboxes -while keeping the same Open-Inspect control plane, web app, GitHub OAuth, and Slack/GitHub -integrations. - -## Configuration - -Set these values in `terraform/environments/production/terraform.tfvars`: - -```hcl -sandbox_provider = "opencomputer" - -opencomputer_api_url = "https://app.opencomputer.dev/api" -opencomputer_api_key = "osb_..." - -# Optional: use an existing OpenComputer template by name. -# Leave empty to let Terraform build and manage the OpenInspect runtime template. -opencomputer_template = "" -``` - -The OpenComputer provider also needs the normal Open-Inspect values such as Cloudflare, GitHub App, -Anthropic, and web app configuration. See [GETTING_STARTED.md](./GETTING_STARTED.md) for the full -deployment flow. - -## Template Build - -OpenComputer sandboxes boot from a declarative template that contains: - -- the OpenInspect sandbox runtime -- OpenCode and the OpenCode plugin dependencies -- Python 3.12 runtime dependencies -- GitHub CLI and Git credential helpers -- browser/terminal support tools used by the agent runtime -- certificate and localhost bootstrap commands needed by the sandbox environment - -There are two supported ways to provide this template. - -### Terraform-Managed Template - -This is the recommended path for a normal deployment. - -Leave `opencomputer_template = ""`. When `sandbox_provider = "opencomputer"`, Terraform computes a -hash of the sandbox runtime and OpenComputer template builder source, builds a deterministic -template named like: - -```text -openinspect-runtime- -``` - -and passes that template name to the deployed control plane as `OPENCOMPUTER_TEMPLATE`. - -Run the deployment from the production Terraform directory: - -```bash -cd terraform/environments/production -terraform init -terraform apply -``` - -Terraform rebuilds the managed template when relevant runtime or builder files change. - -### Manual Template - -Use this path when you want to build or test a template before wiring it into Terraform. - -```bash -OPENCOMPUTER_API_URL="https://app.opencomputer.dev/api" \ -OPENCOMPUTER_API_KEY="osb_..." \ -OPENCOMPUTER_TEMPLATE="openinspect-runtime" \ -npm run build:opencomputer-template -``` - -Then set the same template name in Terraform: - -```hcl -sandbox_provider = "opencomputer" -opencomputer_template = "openinspect-runtime" -``` - -When `opencomputer_template` is non-empty, Terraform skips the managed template build and uses that -exact template name. - -## Runtime Behavior - -The OpenComputer provider creates fresh sandboxes from the configured template. The sandbox starts -the OpenInspect runtime, which: - -1. clones or syncs the selected GitHub repository -2. prepares repo-local OpenCode tools and skills -3. starts OpenCode inside the sandbox -4. starts the OpenInspect bridge process -5. streams agent events back through the control plane - -OpenComputer owns sandbox hibernation and wake-up. Open-Inspect should not need to manually stop -OpenComputer sandboxes during normal operation. - -## Required Secrets - -Terraform passes these provider-level values to the control plane: - -- `OPENCOMPUTER_API_URL` -- `OPENCOMPUTER_API_KEY` -- `OPENCOMPUTER_TEMPLATE` -- `ANTHROPIC_API_KEY` (optional — bound only when the deployment configured one) - -The runtime also receives repository credentials from Open-Inspect for Git operations. If you use -additional model providers or custom agent tools, add those keys through Open-Inspect's secrets -settings. For example, Z.AI Coding Plan GLM models require `ZHIPU_API_KEY`. See -[SECRETS.md](./SECRETS.md). - -## Verify - -After `terraform apply`, verify: - -1. The control plane is healthy: - - ```bash - curl https://open-inspect-control-plane-..workers.dev/health - ``` - -2. The OpenComputer dashboard shows the OpenInspect template as ready. - -3. Starting a session in the web app creates an OpenComputer sandbox and reaches `Connected`. - -4. Inside the session, ask a simple repo question such as: - - ```text - tell me about this repository - ``` - -If a session starts but never produces agent output, check the control-plane Worker logs and the -OpenComputer sandbox logs for runtime startup, bridge connection, and OpenCode health events. - -## Common Issues - -### Template Was Not Built - -If `opencomputer_template` is empty, Terraform should build the managed template during -`terraform apply`. If you set `opencomputer_template`, make sure that exact template name already -exists in OpenComputer and is ready. - -### Wrong API URL - -Use the API base URL, normally: - -```text -https://app.opencomputer.dev/api -``` - -The builder normalizes a missing `/api` suffix, but the Terraform value should still be explicit. - -### Missing Repository Access - -Repository access still comes from the configured GitHub App installation. If the dashboard shows no -repositories or a sandbox cannot clone a repo, check the GitHub App installation permissions before -debugging OpenComputer. - -### LLM/API Key Problems - -The control plane passes `ANTHROPIC_API_KEY` for the default Claude models when the deployment -configured one; otherwise the key comes from Open-Inspect's secrets settings, which also override -it. If OpenCode reports a model or provider error, confirm the key for the selected model is -available through one of the two, and that the model is available for that account. diff --git a/docs/SECRETS.md b/docs/SECRETS.md index fcfb4e959d..d7b3cd8227 100644 --- a/docs/SECRETS.md +++ b/docs/SECRETS.md @@ -9,12 +9,9 @@ control plane instead of being injected into sandboxes. ## Quick Start -1. Open your Open-Inspect web app and go to **Settings** -2. Navigate to the scope you want: - - **Global or repository secrets**: the **Secrets** tab (selected by default) — use the scope - dropdown at the top to choose **All Repositories (Global)** or a specific repository - - **Environment secrets**: the **Environments** tab — open the environment and switch to its - **Secrets** tab +1. Open your Open-Inspect web app. +2. Open **Settings > Secrets** for global/repository secrets, an environment's **Secrets** tab under + **Settings > Environments**, or a team's **Secrets** tab for team secrets. 3. Click **Add secret**, enter a key and value, then click **Save** That's it — the next sandbox you launch from that scope will have the secret available as an @@ -27,30 +24,36 @@ environment variable. | Scope | Applies to | Use case | | --------------- | --------------------------------------- | ---------------------------------------------------------------------- | | **Global** | All sessions | API keys shared across projects (`ANTHROPIC_API_KEY`, `ZHIPU_API_KEY`) | +| **Team** | Sessions owned by that team | Credentials shared within the owning team | | **Repository** | Sessions launched from that repo | Repo-specific credentials (`STRIPE_SECRET_KEY`, `AWS_ACCESS_KEY_ID`) | | **Environment** | Sessions launched from that environment | Credentials curated for a multi-repository environment (see below) | Global and repository secrets are managed under **Settings > Secrets**; environment secrets are -managed on the **Secrets** tab of each environment under **Settings > Environments**. +managed on the **Secrets** tab of each environment under **Settings > Environments**. Team secrets +are managed on the team's **Secrets** tab by team leads and workspace Owners/Administrators. The +team-secret list/write/delete routes require a human user; bots and sandbox credentials cannot +manage them. Secret values are not returned, even to managers. -**Precedence**: Repository (or environment) secrets override global secrets with the same key. When -viewing a repository's secrets, inherited global keys are shown in a read-only section with a -"Global" badge. If you override a global key at the repo or environment level, the global entry -shows which scope overrode it. +**Precedence**: global, then owning team, then repository or environment; later scopes override the +same key. Workspace-owned sessions have no team layer. When viewing a repository's secrets, +inherited global keys are shown in a read-only section with a "Global" badge. If you override a +global key at the repo or environment level, the global entry shows which scope overrode it. ### Which secrets a session receives -A session receives **global secrets plus its session target's secrets** — the session target is -whatever you picked when creating the session: +A session receives **global secrets + owning-team secrets (if any) + session-target secrets**, in +that order. Team membership alone does not select secrets: the session's owning team does. The +session target is whatever you picked when creating the session: -- **Single repository** (web picker, Slack, GitHub, Linear): global + that repository's secrets. -- **Environment**: global + that **environment's** secrets only. The repositories inside the - environment do **not** contribute their repository secrets — environments are curated, so a key - added to a repository never silently lands in every environment containing it. To reuse a - repository secret, import it (below) or move it to global scope. -- **Ad-hoc multi-repository session** ("Multiple repositories" in the picker): global + each - selected repository's secrets. On key collisions the **primary repository** (first in the list) - wins. +- **Single repository**: global + owning team (if any) + that repository's secrets. +- **Environment**: global + owning team (if any) + that **environment's** secrets. Its repositories + do **not** contribute their repository secrets: environments are curated, so a key added to a + repository never silently lands in every environment containing it. To reuse a repository secret, + import it (below) or move it to global scope. +- **Ad-hoc multi-repository session** ("Multiple repositories" in the picker): global + owning team + (if any) + each selected repository's secrets. On key collisions the **primary repository** (first + in the list) wins. +- **No repository**: global + owning team (if any). The new-session picker states this disclosure for environment and multi-repository selections. @@ -61,6 +64,12 @@ environment: pick the source repository, select the keys, and the values are cop control-plane-side (never displayed). Imports are **copies** — if you later rotate the value on the repository, re-import it or update the environment secret directly. +Imports require destination environment management access and `environments.secrets.manage`. The +source repository must pass the workspace repository-grant check (a lead in an active granting team, +or a workspace Owner/Administrator, when the repository has grants). A team-owned destination also +requires its owning team's grant to cover the source repository; access through another team does +not substitute for that grant. Repository secrets remain workspace resources, not team-owned ones. + ### When to use global secrets Use global secrets for keys that every session needs regardless of which repository it runs against. @@ -71,12 +80,13 @@ The most common example: | `ANTHROPIC_API_KEY` | Required for Claude models, unless the deployment configured a fleet-wide key (see below) | | `DEEPSEEK_API_KEY` | Required for DeepSeek models with any sandbox provider | | `ZHIPU_API_KEY` | Required for Z.AI Coding Plan GLM models with any sandbox provider | +| `OPENCODE_API_KEY` | Required for OpenCode Zen and OpenCode Go models with any sandbox provider | > **Claude models**: add `ANTHROPIC_API_KEY` as a global secret after deploying. A deployment can > instead set `anthropic_api_key` in Terraform to inject one fleet-wide key into Modal session -> sandboxes and OpenComputer sandboxes; that is optional, and a global secret of the same name takes -> precedence over it. With neither, Claude sessions fail with "Model not found." See -> [Getting Started](GETTING_STARTED.md) for details. +> sandboxes; that is optional, and a global secret of the same name takes precedence over it. With +> neither, Claude sessions fail with "Model not found." See [Getting Started](GETTING_STARTED.md) +> for details. ### When to use repository secrets @@ -116,14 +126,14 @@ Click the delete button next to any secret row and confirm. ## Limits -| Constraint | Limit | -| -------------------------------- | ------------------------------------------------------- | -| Max secrets per scope | 50 | -| Max key length | 256 characters | -| Max value size | 16 KB | -| Max total value size (per scope) | 64 KB | -| Max combined size per session | 128 KB (global + session target, after merging) | -| Key format | `[A-Za-z_][A-Za-z0-9_]*` (letters, digits, underscores) | +| Constraint | Limit | +| -------------------------------- | ------------------------------------------------------------- | +| Max secrets per scope | 50 | +| Max key length | 256 characters | +| Max value size | 16 KB | +| Max total value size (per scope) | 64 KB | +| Max combined size per session | 128 KB (global + owning team + session target, after merging) | +| Key format | `[A-Za-z_][A-Za-z0-9_]*` (letters, digits, underscores) | If the merged payload for a session (or an image build) exceeds the combined cap, the spawn fails with an error that attributes bytes per contributing scope so you know what to trim. This mostly @@ -145,6 +155,21 @@ If you try to save a reserved key, the UI will show a validation error. ## Security +### GitHub App credentials are not session secrets + +Fresh and restored sandboxes obtain scoped Git credentials on demand from the control plane. Keep +the GitHub App private key in the control plane; do not add it as a global, team, repository, or +environment secret. The optional GitHub bot Worker still needs its own App credential bindings. + +The legacy Modal `github-app` secret is optional and is no longer required for sandbox Git +credentials. This does not remove the required Modal `internal-api` secret (`MODAL_API_SECRET` and +`ALLOWED_CONTROL_PLANE_HOSTS`) or the `llm-api-keys` secret object. The latter may contain an empty +model key when sessions receive their model credentials from the control-plane secret store. +Terraform provisions these required Modal secrets. See +[Modal setup](../packages/modal-infra/README.md#prerequisites). + +### Stored secret protection + - Secrets are encrypted with **AES-256-GCM** before being stored in the database - Values are **never returned by the API** after saving — only key names are visible - Secrets are decrypted at sandbox creation time and injected as environment variables @@ -158,7 +183,7 @@ and requests short-lived access through `POST /sessions/:id/provider-auth/:provi Provider-account mode removes that provider's canonical API key from the sandbox environment so the runtime cannot bypass the selected subscription. API-key mode continues to use ordinary global, -repository, or environment secrets. +team, repository, or environment secrets. ### Legacy managed OAuth coexistence @@ -176,38 +201,63 @@ XAI_OAUTH_ACCESS_TOKEN XAI_OAUTH_ACCESS_TOKEN_EXPIRES_AT ``` +Team secrets are **not** a legacy OAuth broker source. The broker reads global and target scopes +(the primary repository or environment), not the team layer; putting a refresh token in team secrets +does not configure legacy subscription authentication. + Do not reuse the same rotating refresh token in both systems. Operators may remove legacy keys once the legacy-bound sessions that depend on them are no longer needed. ### Secrets and prebuilt images -Image builds (repository images and environment images) run your `.openinspect/setup.sh` with the -same secrets a session would get. Anything the script **persists to disk** — an `.npmrc`, a `.env` -file, a downloaded credential — is captured in the image and re-served to every session that boots -from it, even after you rotate the secret. Two guidelines: +Image builds run your `.openinspect/setup.sh` with secrets selected for the build scope: + +- **Repository images**: global + repository secrets, **never team secrets**, because these images + are shared across teams. +- **Environment images**: global + the environment's owning team (if any) + environment secrets; + member-repository secrets do not flow in. A team environment's image is selected only for sessions + owned by the same team. A workspace environment build has no team layer, even if a team session + later uses it. + +Anything the script **persists to disk**, such as an `.npmrc`, a `.env` file, or a downloaded +credential, is captured in the image and re-served to every session that boots from it, even after +you rotate the secret. Two guidelines: - **Avoid writing long-lived secrets to disk in `setup.sh`.** Read them from the environment at runtime (they are re-injected fresh on every session) instead of baking them into files. -- **Environment-secret changes invalidate prebuilt images automatically**: saving an environment's - secrets supersedes its existing ready image and triggers a rebuild, so a revoked value cannot keep - serving from an old image. Rotating **repository or global** secrets does _not_ invalidate images - — stale on-disk material persists until the next commit-triggered rebuild, which is another reason - to keep secrets out of the image filesystem. +- **Environment-secret changes invalidate that environment's images automatically. Team-secret + writes/deletes invalidate images of environments owned by that team**, with best-effort rebuild + scheduling for prebuild-enabled environments. Invalidated images are not used for new boots; this + does not erase files from already-running sandboxes or their snapshots. Rotating **repository or + global** secrets does _not_ invalidate images; stale on-disk material persists until the next + commit-triggered rebuild, which is another reason to keep secrets out of the image filesystem. + +Where the trust boundary sits: Open-Inspect's own build plumbing never persists a credential into an +image. The build's callback token stays in process memory, and the clone token and scope secrets +reach only the build process and the setup scripts it starts — never the provider's container +configuration, never a file the image captures. What a setup script does with those values in its +environment is the script's own decision: Open-Inspect keeps no copy of its own, but a value the +script writes to disk is captured in the image exactly as described above. Treat a scope's prebuilt +image as no less sensitive than the scope's secrets. --- ## Common Examples -| Key | Scope | Purpose | -| ------------------- | ------ | ----------------------------------------------------- | -| `ANTHROPIC_API_KEY` | Global | Claude API access | -| `OPENAI_API_KEY` | Global | OpenAI API access when a session selects API-key mode | -| `XAI_API_KEY` | Global | xAI API access when a session selects API-key mode | -| `DEEPSEEK_API_KEY` | Global | DeepSeek API access | -| `ZHIPU_API_KEY` | Global | Z.AI Coding Plan GLM access | -| `DATABASE_URL` | Repo | Database connection string | -| `AWS_ACCESS_KEY_ID` | Repo | AWS credentials for a specific project | -| `STRIPE_SECRET_KEY` | Repo | Stripe API key for a specific project | +| Key | Scope | Purpose | +| ---------------------------- | ----------------------- | ------------------------------------------------------------------------------ | +| `ANTHROPIC_API_KEY` | Global | Claude API access | +| `OPENAI_API_KEY` | Global | OpenAI API access when a session selects API-key mode | +| `XAI_API_KEY` | Global | xAI API access when a session selects API-key mode | +| `DEEPSEEK_API_KEY` | Global | DeepSeek API access | +| `ZHIPU_API_KEY` | Global | Z.AI Coding Plan GLM access | +| `OPENCODE_API_KEY` | Global | OpenCode Zen and OpenCode Go access | +| `OPENAI_OAUTH_REFRESH_TOKEN` | Global/repo/environment | Legacy OpenAI Codex via ChatGPT subscription ([setup guide](OPENAI_MODELS.md)) | +| `OPENAI_OAUTH_ACCOUNT_ID` | Global/repo/environment | Legacy OpenAI Codex via ChatGPT subscription ([setup guide](OPENAI_MODELS.md)) | +| `XAI_OAUTH_REFRESH_TOKEN` | Global/repo/environment | Legacy SuperGrok access ([setup guide](GROK_MODELS.md)) | +| `DATABASE_URL` | Repo | Database connection string | +| `AWS_ACCESS_KEY_ID` | Repo | AWS credentials for a specific project | +| `STRIPE_SECRET_KEY` | Repo | Stripe API key for a specific project | --- @@ -218,14 +268,16 @@ from it, even after you rotate the secret. Two guidelines: If you see "Model not found" errors, verify the selected provider authentication mode first. For provider-account mode, verify the account and model entitlement. For API-key mode, add the required key to the session's secret scope. OpenAI uses `OPENAI_API_KEY`; xAI uses `XAI_API_KEY`; Claude uses -`ANTHROPIC_API_KEY`; DeepSeek uses `DEEPSEEK_API_KEY`; Z.AI Coding Plan uses `ZHIPU_API_KEY`. For -subscription authentication, follow the provider-account setup guidance in -[OpenAI models](OPENAI_MODELS.md) or [Grok models](GROK_MODELS.md). +`ANTHROPIC_API_KEY`; DeepSeek uses `DEEPSEEK_API_KEY`; Z.AI Coding Plan uses `ZHIPU_API_KEY`; +OpenCode Zen and OpenCode Go both use `OPENCODE_API_KEY`, and an `opencode-go/*` model additionally +needs an active Go subscription on that key. For subscription authentication, follow the +provider-account setup guidance in [OpenAI models](OPENAI_MODELS.md) or +[Grok models](GROK_MODELS.md). ### Secret not appearing in sandbox -1. Verify the secret is saved under the correct scope (global, the specific repo, or the - environment) +1. Verify the secret is saved under the correct scope (global, the owning team, the specific repo, + or the environment) 2. Check that the key isn't in the reserved keys list above 3. New secrets only apply to **new** sandboxes — restart your session to pick up changes 4. For sessions launched from an **environment**: repository secrets do not flow in. Add the key to diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index f90217c752..4bcc3fe725 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -23,7 +23,7 @@ sign-in are supported, while GitHub App repository credentials remain required f Required: -- Node.js `22+` (minimum supported: `20+`) +- Node.js `24+` - npm - Git @@ -205,12 +205,23 @@ For full infrastructure setup, use: Critical notes before deploy: +- After [Owner bootstrap](GETTING_STARTED.md#step-9-bootstrap-the-workspace-owner), + [create the first team](GETTING_STARTED.md#step-10-create-the-first-team-and-test-a-session). The + creator becomes Lead; choose membership policy and default visibility, grant repositories, + join/add operators, and create a team-owned test session. +- Fresh deployments should explicitly set `teams_enforcement = "on"` in Terraform to deploy + `TEAMS_ENFORCEMENT=on`. Runtime and Terraform defaults remain `shadow`. Existing deployments must + review production `shadow_denied:*` authorization audit entries before opting in; no completed + default-flip audit gate is established by this guide. - Build workers before running Terraform apply. - Build `@open-inspect/shared` first. - Use two-phase Terraform deploy for DO/service bindings. - For Modal deployments, eagerly build the Sandbox image with `uv run python deploy.py --build-sandbox-image`, then deploy with `uv run modal deploy deploy.py` (not `src/app.py`). +- Modal's legacy `github-app` secret is not required for fresh or restored sandbox Git credentials; + keep the App private key in the control plane (and the GitHub bot's own bindings when enabled). + Retain the required Modal `llm-api-keys` and `internal-api` secret objects. - Existing sessions keep their pinned authentication. Remove legacy OAuth keys only after dependent legacy-bound sessions are no longer needed. diff --git a/docs/TEST_REDUCTION.md b/docs/TEST_REDUCTION.md new file mode 100644 index 0000000000..35c4bc470e --- /dev/null +++ b/docs/TEST_REDUCTION.md @@ -0,0 +1,195 @@ +# Coverage-Guided Test Reduction + +## Results + +These figures record the initial reduction measured against `d343cac`, before later changes from +`main` were merged. Subsequent merge resolutions retain newly introduced regression tests rather +than restoring the removed redundant suites, so these are historical counts, not current-main +totals. + +Removed **3,414 test cases** and **278 complete test files** across eight packages. The measured +suites decreased from 14,587 to 11,173 cases, a **23.4% reduction**. Counts include four unchanged +skipped tests. + +The limit is a maximum **three-percentage-point decrease in each coverage metric, per package**, not +an aggregate average that could hide a large loss in one package. The historical comparisons below +were recomputed from saved counters with identical production-only exclusions on both sides. Every +statement, branch, function, and line denominator remains unchanged within that comparison. + +| Package | Before | After | Removed | Largest Coverage Drop | +| ------------------------- | -----: | ----: | ------: | --------------------: | +| control-plane, both hosts | 7,916 | 5,841 | 2,075 | 2.34 pp | +| web | 2,708 | 1,881 | 827 | 2.40 pp | +| shared | 1,093 | 903 | 190 | 2.28 pp | +| slack-bot | 512 | 397 | 115 | 2.27 pp | +| linear-bot | 267 | 228 | 39 | 2.04 pp | +| github-bot | 146 | 134 | 12 | 0.57 pp | +| sandbox-runtime | 1,404 | 1,278 | 126 | 0.90 pp | +| modal-infra | 541 | 511 | 30 | 0.00 pp | + +Other suites, including docs, sandbox-images, native Node tooling tests, and Terraform contracts, +were not reduced and are not included in these totals. + +### TypeScript Coverage + +Each cell shows baseline coverage followed by coverage after removal, in percent. + +| Package | Statements | Branches | Functions | Lines | +| ------------- | -------------- | -------------- | -------------- | -------------- | +| control-plane | 92.45 -> 90.78 | 84.82 -> 82.48 | 96.95 -> 96.09 | 94.11 -> 92.63 | +| web | 75.79 -> 73.39 | 74.55 -> 72.17 | 75.28 -> 73.05 | 76.84 -> 74.59 | +| shared | 93.63 -> 91.91 | 84.02 -> 81.94 | 91.90 -> 89.62 | 94.62 -> 92.93 | +| slack-bot | 90.24 -> 88.71 | 81.09 -> 78.82 | 95.46 -> 93.65 | 90.41 -> 89.47 | +| linear-bot | 86.61 -> 85.18 | 74.06 -> 72.19 | 90.47 -> 88.43 | 87.82 -> 86.48 | +| github-bot | 93.65 -> 93.65 | 88.57 -> 88.00 | 93.75 -> 93.75 | 94.69 -> 94.69 | + +### Python Coverage + +Coverage.py reports executable lines as statements and does not provide Vitest-style function +coverage. Statement and branch percentages were compared separately, not just the combined score. + +| Package | Statements / Lines | Branches | Combined | +| --------------- | ------------------ | -------------- | -------------- | +| sandbox-runtime | 89.83 -> 89.49 | 81.36 -> 80.46 | 87.87 -> 87.40 | +| modal-infra | 96.20 -> 96.20 | 89.92 -> 89.92 | 95.15 -> 95.15 | + +## Selection + +Per-suite coverage was compared using original source locations, then candidate deletions were +evaluated cumulatively. A point is redundant only while another retained suite still covers it; +individually redundant files are not necessarily redundant when removed together. Python test +contexts also identified duplicated cases within suites and redundant parameter combinations. + +The reduction favors removing mocked SQL, handler delegation, helper, and orchestration tests when +retained integration or higher-level behavioral tests exercise the implementation. It keeps: + +- All 140 control-plane workerd integration files, using real D1 and Durable Object storage. +- All Node-host and storage conformance suites. +- Web component and hook integration suites. +- Core authentication, signature, cookie identity, migration, architecture, and type contracts. +- Focused sandbox manager signature, spawn-admission, and late-provider-result race regressions. +- Real local-process, Git, shell, socket, and tool tests in the sandbox runtime. + +Coverage overlap is not assertion equivalence. Some isolated input permutations, error wording, +provider error-classification matrices, and interleavings no longer have dedicated assertions. The +retained integration tests cover their broader behavior, but this reduction does not claim to +preserve every old assertion or to be a mathematically optimal minimum test set. + +## Reproducing Coverage + +Build shared first and run heavyweight checks sequentially. `scripts/coverage-baseline.json` stores +the normalized baseline counters and the three-point budget. `scripts/coverage-policy.ts` derives +floors from those exact counters, rounding up to two decimals rather than loosening the budget. The +Vitest configs and report checker share this policy. Python statement and branch floors are enforced +independently; a high combined score cannot hide a branch regression. + +`.github/workflows/coverage.yml` runs the entire policy on every PR targeting `main` and every push +to `main`, without path filters or `continue-on-error`. Control-plane, web, other TypeScript, and +Python coverage run in parallel jobs. Control-plane coverage is further split into Vitest shards +(`COVERAGE_SHARD=true` skips the per-shard floor); `Coverage (control-plane)` merges their blob +reports and enforces the full-suite floor on the merged result. The final `Coverage` check fails if +any job fails and uploads the combined coverage artifact. The workflow tests its gate, including CLI +failure on low Python branch coverage. + +Repository rules are separate from workflow files: an administrator must add `Coverage` as a +required status check in the main ruleset. The authenticated integration cannot administer rules +(HTTP 403), and the current effective main rules have no required-status-check rule. This external +setting remains outstanding; the workflow alone is not claimed to make GitHub merges conditional. + +```bash +npm run build -w @open-inspect/shared +npm run test:coverage -w @open-inspect/control-plane +npm run test:coverage -w @open-inspect/web +npm run test:coverage -w @open-inspect/shared +npm run test:coverage -w @open-inspect/slack-bot +npm run test:coverage -w @open-inspect/linear-bot +npm run test:coverage -w @open-inspect/github-bot + +uv run --frozen --project packages/modal-infra --extra dev pytest packages/modal-infra/tests --cov=packages/modal-infra/src --cov-branch --cov-report=json:packages/modal-infra/coverage/coverage.json +node scripts/check-coverage.mjs modal-infra packages/modal-infra/coverage/coverage.json +uv run --frozen --project packages/sandbox-runtime --extra dev pytest packages/sandbox-runtime/tests --cov=packages/sandbox-runtime/src --cov-branch --cov-report=json:packages/sandbox-runtime/coverage/coverage.json +node scripts/check-coverage.mjs sandbox-runtime packages/sandbox-runtime/coverage/coverage.json +npm run test:coverage-gate +``` + +TypeScript JSON summaries are written to each package's `coverage/coverage-summary.json`. For +Python, both statement and branch percentages in the JSON `totals` are checked, rather than treating +`percent_covered` as line coverage. Missing branch counters fail closed. + +The control-plane coverage command now runs both Node and workerd projects in one Istanbul report. +V8 coverage cannot run inside workerd because Workers lack its inspector API. Both control-plane +measurements used the same Istanbul provider. Other packages retain V8. Both sides of the normalized +comparison exclude `.test`/`.spec` implementations, named test helpers/support/fixtures, declaration +files, and the existing `src/index.ts` entrypoint exclusions. Shared additionally excludes its +test-only `src/triggers/testing.ts`. No generic `*helper*` pattern excludes production credential +helpers. Python reports still measure only production `src/` files, so their baselines are +unchanged. + +Vitest 4 already excluded discovered `.test.tsx` suites before this correction: the original web +report contains zero `.test.tsx` or `.test.ts` files. The actual normalization removes two web +fixtures, three control-plane helpers, and one helper each from shared, Slack, and Linear. Counter +filtering, not averages of per-file percentages, produces the recorded baselines. + +## Merge Validation + +After merging `main` at `452b0b9`, six modify/delete conflicts were resolved by retaining focused +upstream regressions for canonical automation owners, bounded D1 parameters, linked-session privacy, +synchronous archive failures, executor audit events, and environment selection equality. The old +redundant cases remain removed, and all upstream integration additions are retained. + +The following are historical validation percentages from that merge, before helper/fixture +normalization, not a new before/after benchmark. + +| Package | Passed | Skipped | Statements | Branches | Functions | Lines | +| ------------------------- | -----: | ------: | ---------: | -------: | --------: | ----: | +| control-plane, both hosts | 6,002 | 1 | 90.92 | 82.66 | 95.86 | 92.72 | +| web | 1,965 | 0 | 74.35 | 73.05 | 74.15 | 75.54 | +| shared | 909 | 0 | 92.05 | 81.91 | 89.83 | 93.07 | +| sandbox-runtime | 1,279 | 3 | 89.50 | 80.49 | N/A | 89.50 | + +Sandbox-runtime combined coverage is 87.42%. The eleven focused conflict-resolution cases also pass +independently. Repository typechecks, ESLint, and formatting checks were rerun for the merge. + +## Security Review Follow-Up + +Three focused security checks were restored without restoring the removed suites: single-session +export refuses a readable session without `sessions.export` before loading its trace; the public +prompt route rejects caller-provided `authorId` before runtime dispatch; and failed +repository-scoped credential minting remains unavailable without retrying with broader credentials. +The route checks use real D1/DO sessions and verified browser credentials. The planner check injects +only the mint failure and retains the real scope resolver and planning path. + +## Deep Review Follow-Up + +Compact tests now preserve the contracts aggregate coverage cannot establish: + +- Real D1 environment membership/current-team grant intersection and TOCTOU rejection, including + unavailable credentials without broad-auth retry. +- Real SQLite post-encryption generation, status, fence, and provider-reference predicates, with + successor URLs/secrets unchanged and successful controls. +- Shutdown-handler-before-ACK and no-ACK-on-failure for both critical shutdown event types. +- Incoming bridge ACK routing through the real forwarder, including boot-time passthrough. +- Gated same-repository PR conflicts, claim release after failure, and independent-repository + concurrency through the real service and claims object. + +Production-only runs at `6f4c32f` pass all floors. These validation results are separate from the +historical fixed-denominator comparison above. + +| Package | Passed | Skipped | Statements | Branches | Functions | Lines | +| ------------------------- | -----: | ------: | ---------: | -------: | --------: | ----: | +| control-plane, both hosts | 6,038 | 1 | 90.97 | 82.74 | 96.21 | 92.77 | +| web | 1,965 | 0 | 74.15 | 73.04 | 73.83 | 75.37 | +| shared | 909 | 0 | 92.10 | 82.04 | 89.89 | 93.13 | +| slack-bot | 397 | 0 | 88.71 | 78.95 | 93.65 | 89.47 | +| linear-bot | 228 | 0 | 85.18 | 72.19 | 88.43 | 86.48 | +| github-bot | 134 | 0 | 93.65 | 88.00 | 93.75 | 94.69 | +| modal-infra | 511 | 0 | 96.20 | 89.92 | N/A | 96.20 | +| sandbox-runtime | 1,285 | 3 | 89.56 | 80.70 | N/A | 89.56 | + +## Additional Main Merge + +After merging `main` at `5abc1fb`, eight test conflicts were resolved by retaining the new analytics +source/user attribution, Slack channel/team boundary, and channel-binding audit assertions. The +older redundant cases remain removed. All 57 focused resolution cases, all eight full coverage +suites, and the unchanged coverage floors pass. No baseline or coverage budget was reset for the +upstream features. The required-check administration setting remains a separate external step. diff --git a/docs/TRACE_EXPORT.md b/docs/TRACE_EXPORT.md new file mode 100644 index 0000000000..eb5d75c778 --- /dev/null +++ b/docs/TRACE_EXPORT.md @@ -0,0 +1,111 @@ +# Trace export (schema 2) + +Trace export is pull-only NDJSON. Each line is an independent JSON object described by +[`trace-export.v2.schema.json`](schemas/trace-export.v2.schema.json). The control plane does not +send exports to a vendor. This release supports manual downloads, not scheduled warehouse loading. + +## Access and routes + +Both routes require an active user with `sessions.export` (Owners and Administrators by default, or +a custom role granting it). `sessions.read` alone is insufficient; the bot services cannot export. +Manual bulk downloads use the web app's `/api/sessions/export` proxy with an authenticated browser +session; the web app holds its service secret and signs the control-plane request server-side. The +web app also proxies single-session downloads at `/api/sessions/:id/export`. There is no bearer API +token path for export. Treat downloaded files as sensitive session data. + +| Route | Behavior | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /sessions/export` | Bulk, paginated. Defaults to `scope=sessions`, no included trace, `limit=100`. | +| `GET /sessions/:id/export` | Exactly one session, full `messages,events,usage` trace by default. Accepts `include` and `format`; `scope` returns 400. No bulk cursor or run export on this route. Missing session returns 404. | + +Bulk query parameters: + +| Name | Meaning | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scope` | `sessions` (default) orders sessions newest first by `(createdAt,id)`; `runs` orders root families by root creation time. | +| `createdAfter`, `createdBefore` | Inclusive epoch **milliseconds**, applied to session creation in `sessions` scope and root creation in `runs` scope. Supply both for a repeatable window. | +| `include` | Comma-separated subset of `messages,events,usage`, in any order; omitted means metadata only. | +| `format` | `full` (default) or `compact` (changes included event output only). | +| `limit` | Sessions per page: 1–500 without `include` (default 100); 1–5 with any `include` (default 5). Counts session lines, not families. | +| `cursor` | Opaque `nextCursor` from the prior page's cursor line. Keep the same scope, window, include and format on every page. Invalid or wrong-scope cursors return 400. | + +## Manual download + +For one session, open its page in the web app and select **Download trace**. For a bulk window, sign +in as an operator with `sessions.export`, then open a URL on the **web app** origin such as: + +```text +https://your-web-app.example/api/sessions/export?scope=runs&include=messages%2Cevents%2Cusage&format=compact&createdAfter=1767225600000&createdBefore=1767311999999&limit=5 +``` + +Save the NDJSON response locally. If its last line has `type: "cursor"`, append its URL-encoded +`nextCursor` as the `cursor` parameter and save the next page; repeat until a page has no cursor +line. Keep the same scope, date window, include and format on each request. Inspect every page for +`session_error` or `error` lines: these are not successful session exports. Re-export the window if +the index changes during pagination. No browser cookie or web service secret needs to be copied into +a CLI. Unattended export requires a separate machine credential, which is not available yet. + +The response has `Content-Type: application/x-ndjson` and `Cache-Control: private, no-store`. Lines +are `schemaVersion: 2` and one of: + +| `type` | Meaning | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session` | Session-index metadata (identity, hierarchy, owner, model/provider, repositories, PRs, token totals, cost and timestamps), plus only the requested trace arrays. Nullable fields are present; unrequested arrays are absent. | +| `session_error` | An included trace could not be read; **no partial session line** is emitted for this ID. `reason` is `http_error` (with `status`), `runtime_failure`, `page_cap_reached`, or `trace_budget_exceeded`. Retry the window or use `format=compact`/fewer includes; do not count this as an exported session. | +| `cursor` | Last line of a nonterminal page; pass `nextCursor` on the next request. A terminal page has no cursor line. | +| `error` | Terminal streaming error, for example a failed index read. Treat the page/window as incomplete. | + +Each included session trace is read via **one** runtime `GET /internal/trace-export` call in a +single storage snapshot. Its collections share a 4 MiB serialized-response budget and at most 25 +repository reads of up to 100 rows each; the control-plane runtime read also has a 10-second +timeout. Budget or page-cap failures replace the session line with `session_error`, not a partially +populated session. Within a schema 2 session line, **messages, events and usage are all oldest +first**. Events use timeline sequence for equal timestamps. Schema 1 listed messages newest first; +reverse them when migrating a consumer. + +## Runs and consistency + +With `scope=runs`, the root's `createdAt` determines window membership, even if a child was created +outside the window. Roots sort newest first; within a root family the root precedes descendants, by +spawn depth, creation time and ID. All lines of a family stay consecutive but **a family can +continue on the next page**. `rootSessionId` is normally the root ID; use +`COALESCE(rootSessionId,id)` for legacy rows without one. Whole runs are available only through the +bulk route. + +Pagination is best-effort, **not a database snapshot**. On the first page each scope captures +`MAX(rowid)` and fences later inserts; a late insert is normally excluded. If the highest-rowid +session is deleted, SQLite can reuse its rowid and a rare late insert may slip through. Deletions +are not fenced: deleting a root mid-export re-roots children, so a run can be incomplete in that +export. Re-export the window to recover it. Concurrent mutations can cause omissions or duplicates; +de-duplicate on session `id` when combining overlapping or rerun windows. Orphaned children whose +root row no longer exists are excluded by the run join. + +## Compact events and known gaps + +`format=compact` leaves session metadata, messages and usage unchanged. For `tool_call` event data: + +- A `Read`/`read` file output becomes `data.compacted: {output:"file_read",originalChars:N}`. +- Duplicate output may become `data.compacted: {output:"ref",ref:""}` when that is + smaller. `ref` points to the **latest** occurrence, which appears _later_ in the oldest-first + exported array. Resolve references by event ID, not array position. +- A unique long output is shortened to 4,096 UTF-16 code units with + `data.compacted: {output:"truncated",originalChars:N}`. The index is capped at 256 outputs and 1 + MiB of characters; once full, further outputs may not deduplicate. Full format still preserves the + stored value. `tool_call.truncated` (inside event `data`) instead denotes fields truncated by the + upstream bridge **before persistence**; compact export cannot restore them. + +The persisted timeline has tool output in `tool_call.data.output`. Separately emitted `tool_result` +events are also persisted and exported when present; consumers must handle both types. Per-step +usage is raw harness-reported data: step boundaries and counts are **not comparable across +harnesses**. Session token totals are index projections; older sessions may have zero-valued totals +without raw step usage. + +## Versioning + +The line discriminator and `schemaVersion` form the published contract. Additive fields can arrive +within a major schema version; consumers **must ignore unknown fields**. A breaking change requires +a new major version and a new JSON Schema file. Schema 2 publishes the fields introduced since +schema 1, changes the message array to oldest-first, and renames the budget error to +`trace_budget_exceeded`. Regenerate the schema after changing shared Zod types with +`node packages/control-plane/scripts/generate-trace-export-schema.mjs`; the route test detects +drift. diff --git a/docs/VERCEL_SANDBOX_PROVIDER.md b/docs/VERCEL_SANDBOX_PROVIDER.md index f3c62f361d..c965bff309 100644 --- a/docs/VERCEL_SANDBOX_PROVIDER.md +++ b/docs/VERCEL_SANDBOX_PROVIDER.md @@ -118,6 +118,8 @@ Vercel sandboxes are explicitly stopped by Open-Inspect when they should no long - Inactive Vercel sessions are snapshotted and stopped by the lifecycle manager. - Runtime-created snapshots use `VERCEL_SNAPSHOT_EXPIRATION_MS`; the base runtime snapshot does not expire by default. +- Every sandbox is created with `persistent: false`, so a stop never leaves an automatic Vercel + snapshot. Open-Inspect takes every snapshot it needs explicitly. Existing generated base snapshots are not automatically deleted. Treat them like deploy artifacts: keep the current snapshot, and delete old snapshots manually if you need to reclaim quota. diff --git a/docs/adr/0004-sandbox-checkpoint-and-shutdown.md b/docs/adr/0004-sandbox-checkpoint-and-shutdown.md new file mode 100644 index 0000000000..4d7064bf1d --- /dev/null +++ b/docs/adr/0004-sandbox-checkpoint-and-shutdown.md @@ -0,0 +1,153 @@ +# ADR 0004: Sandbox Checkpoint and Shutdown Ownership + +## Status + +Accepted. + +## Context + +A sandbox execution may have a provider-enforced lifetime. Before its useful lifetime ends, the +control plane must stop admitting work, quiesce execution, save recoverable state, and confirm that +the source has stopped. A successful checkpoint is not permission to resume the retiring execution. +The control plane may restart during any step, and a lost provider response is not proof that the +remote operation failed or stopped. + +The previous preservation interface distributed this guarantee across a lifecycle manager, +preservation coordinator, runtime handler, queue, and push service. Callers assembled admission +rules, transaction boundaries, checkpoint settlement, and recovery decisions themselves. A generic +checkpoint lease hid timers but left its caller responsible for deciding whether release was safe. + +## Decision + +### One authoritative lifecycle boundary + +`SandboxLifecycleManager` owns the externally consumed readiness, work-admission, and recovery +policies. Its internal `SandboxShutdownCoordinator` owns the durable checkpoint-and-shutdown +protocol. Runtime handlers, queue processing, push, and recovery commands use the lifecycle boundary +instead of combining coordinator state with manager flags. + +This is one subsystem with focused collaborators, not two peer lifecycle authorities or one giant +class. Queue acquisition and live push have intentionally different policies: queued work may need +to restore an execution, whereas a push requires a live command-ready execution. + +Generation reservation is one synchronous operation: write the sandbox identity and shutdown +ownership in the same transaction, then announce only after commit. The consumer does not manage a +separate reservation/announcement sequence. + +### Distinguish checkpoint, shutdown, and recovery + +- An **ordinary checkpoint** captures recoverable state and may allow the current execution to + continue, but only when its outcome permits exclusion to be safely released. +- **Graceful shutdown** closes admission for the retiring execution, requests bounded quiescence, + captures recoverable state, and confirms source retirement. Only shutdown-related work may run + after admission closes. Failure or uncertainty must not reopen admission. +- **Recovery** starts a separately authorized execution from saved state. It is not a transition of + the retiring execution back to command readiness. + +Ordinary and terminal capture share ownership of provider invocation, outcome classification, +recording, and operation-scoped settlement. The public operation reports domain outcomes rather than +exposing a generic promise runner and a caller-controlled `finish()`. + +A provider timeout, transport exception, ambiguous unsuccessful response, or lost response is +unknown unless the provider contract establishes a stronger outcome. Aborting a local request or +expiring a timer does not establish remote cancellation. Stale results cannot publish a checkpoint +for a replacement generation or release another operation's exclusion. + +### Settle an unconfirmed ordinary checkpoint + +An ordinary checkpoint persists its operation, request deadline, settle time and follow-up intent +with its exclusion, before the provider call, so a restart can recover its schedule. An unknown +outcome holds exclusion until the settle time. The settle time bounds the control plane's request, +not the remote operation: an artifact the provider completes later is unreferenced. + +A checkpoint that neither stops nor alters its source, taken so that the current execution keeps +serving, may then release exclusion for the same generation once the runtime is demonstrably live. +The uncertain artifact is treated as unsaved, the release is recorded durably in the session +timeline, and the next checkpoint captures the workspace again. A shutdown that claims the session +first waits for the settle time and then captures the source itself. Checkpoints taken before +stopping the source, captures that stop or alter it, and shutdown captures keep their existing +holds: shutdown uncertainty never reopens admission. + +### Keep scheduling bounds separate from retirement evidence + +A conservative request-start-plus-timeout estimate can schedule shutdown early. It cannot prove that +execution has ended: actual creation may have happened later. Persist the evidence needed to +distinguish a scheduling estimate from authoritative expiry. Older records lacking that evidence +must not authorize skipping verified retirement. + +Saved state and source retirement are separate facts. Successful shutdown requires both. Retirement +evidence for an old execution cannot prove that a later, ambiguously invoked restore created no new +execution. + +### Continuation is a session policy, not checkpoint cleanup + +If shutdown interrupts an active prompt, terminalize that prompt once using the existing failure +representation, report its interruption, and leave later prompts pending. This pause is durable: +reconnects, alarms, new prompts, and runtime readiness do not clear it. The user must explicitly +authorize resume before queued work may restore and continue. Never automatically replay the +interrupted prompt; restoring files cannot roll back external effects or establish that a partially +completed task is safe to repeat. + +If shutdown occurs at a clean prompt boundary, queued work may automatically restore into a new +generation after checkpointing and retirement are confirmed. That generation must pass the usual +startup, lifetime, and runtime-readiness gates. The shutdown coordinator reports completion; the +lifecycle/session policy decides whether to wake queued work. + +### Recovery availability and request acknowledgement + +The lifecycle subsystem projects `availableRecoveryActions` using the same eligibility checks it +applies when executing a recovery command. Receipt existence is informational, not permission to +restore: the current generation, source provider, receipt provider, and retirement prerequisites +must agree. The UI renders these actions without reconstructing provider policy. Missing action +metadata means no recovery controls; lifecycle authorization is still checked for every command. + +Recovery commands optionally carry `clientRequestId` for compatibility with existing clients. New +clients receive a sender-only `shutdown_recovery_accepted` acknowledgement or a correlated error. +Acceptance is not proof that restoration completed; the durable shutdown state remains +authoritative. While awaiting acknowledgement, the UI prevents duplicate submissions. A disconnect +or timeout means the result is unconfirmed, not that the operation failed or was cancelled, and +never triggers an automatic retry. Legacy requests without an ID receive no new acknowledgement +message. + +### Compatibility and terminology + +Use shutdown terminology for the internal lifecycle operation and checkpoint/recovery terminology +for the distinct concepts. Retain existing `preservation` wire message identifiers, serialized +fields, and SQLite storage names for compatibility with deployed runtimes and clients; a terminology +cleanup must not silently become a protocol or data migration. Source module, type, and schema +variable names are not wire contracts. Persisted recovery receipts remain internal and are +translated into domain decisions rather than returned as an overloaded artifact identifier. + +Internal policies, repositories, collaborators, and their test files use `shutdown` naming, +including `shutdown-policy.ts` and `sandbox-shutdown-repository.ts`. The shared `sandbox-shutdown` +module exports `SandboxShutdownState` and `sandboxShutdownSchema` without changing their serialized +shape. Runtime `shutdown_preparation.py` exposes `ShutdownPreparationCoordinator`: it owns fencing +and preparation, not provider checkpoint capture or retirement. Internal lifecycle APIs, UI +callbacks, and user-facing wording use shutdown terminology too. + +`MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION` names the existing protocol-support threshold; the +runtime manifest keys, version string, and generation values remain unchanged. Existing log-event +identifiers also remain stable until their dashboard and alert consumers are reviewed explicitly. + +## Consequences + +- Callers no longer reconstruct shutdown safety from multiple collaborators or cleanup callbacks. +- An interrupted prompt requires explicit resume; clean-boundary continuation remains automatic. +- Ambiguous checkpoint responses hold execution until their settle time. A routine checkpoint taken + so the current execution keeps serving, on a provider whose capture neither stops nor alters its + source, then resumes the same generation once its runtime proves live by a fresh heartbeat; any + other stays held for the user. +- Conservative or unproven expiry may require a provider retirement check that was previously + skipped. +- The persisted protocol and existing runtime compatibility floors remain unchanged. Legacy + execution does not acquire confirmed-shutdown guarantees merely through renaming. +- A provider hard deadline may still prevent checkpoint completion. Unknown outcomes stay visible + and fenced; the system does not claim exactly-once external effects or guaranteed recovery. + +## Validation and Rollout + +Require regressions for lost capture responses, lifetime provenance, transaction rollback, +interrupted-prompt pauses across restart, explicit resume without prompt replay, clean-boundary +continuation, and readiness/late-result fencing. Validate the assembled lifecycle with real SQL and +Workerd as well as unit tests. Provider-backed canaries and compatible user recovery controls remain +rollout requirements; this decision does not authorize deployment. diff --git a/docs/images/ui-overview.png b/docs/images/ui-overview.png new file mode 100644 index 0000000000..d292b57662 Binary files /dev/null and b/docs/images/ui-overview.png differ diff --git a/docs/integrations/GITHUB.md b/docs/integrations/GITHUB.md index b19ed68256..3fe638a091 100644 --- a/docs/integrations/GITHUB.md +++ b/docs/integrations/GITHUB.md @@ -7,7 +7,7 @@ threads. This guide is for people using the GitHub integration day to day. If you are installing the GitHub App or deploying the bot worker, start with [Create GitHub App](../GETTING_STARTED.md#step-3-create-github-app) and -[Complete GitHub Bot Setup](../GETTING_STARTED.md#step-7c-complete-github-bot-setup-if-using-github-bot). +[Complete GitHub Bot Setup](../GETTING_STARTED.md#github-bot-optional). --- @@ -42,6 +42,10 @@ App bot through the PR reviewer picker. Use auto-review or `@mention` comments i ## Automatic PR Reviews +**Auto-review new PRs** is the deprecated, workspace-owned review-on-open path. It still works and +does not use mention team routing. For team-owned event-driven reviews, use a GitHub Event +automation owned by the intended team instead. Deprecation does not disable the existing setting. + ### When It Runs When **Auto-review new PRs** is enabled, Open-Inspect starts a review session for newly opened, @@ -52,7 +56,7 @@ non-draft PRs in enabled repositories. The agent inspects the PR diff and posts Auto-review is skipped when: - The PR is a draft -- The PR was opened by the GitHub App bot itself +- The PR was opened by the GitHub App bot itself without its login explicitly allowed as a trigger - The repository is outside the configured GitHub Bot scope - The PR opener is not allowed to trigger the bot - Auto-review is disabled globally or for that repository @@ -65,6 +69,9 @@ follow-up after a draft becomes ready, mention the bot in a PR comment. The agent can submit a general review comment, approve the PR, request changes, or add inline review comments when useful. +An explicitly allowed App bot can trigger review of its own PR, but that self-review can only +comment, not approve or request changes. + --- ## `@Mention` Actions @@ -81,6 +88,30 @@ a GitHub reply: Open-Inspect strips the bot mention before sending the request to the agent. The rest of the comment becomes the prompt. +### Model and Reasoning Overrides + +Start the request with `!model` or `!reasoning` to override the configured model or reasoning effort +for the session that comment starts: + +```text +@my-app[bot] !model openai/gpt-5.6-sol !reasoning high investigate the flaky test +``` + +The flags use the same syntax as Slack: each accepts a space or colon before its value (such as +`!model:anthropic/claude-sonnet-4-6` or `!reasoning:max`), and any flags must appear together at the +start of the request, right after the bot mention. Flags later in the comment are treated as part of +the request. Models must be enabled under **Settings > Models**, and the reasoning value must be +supported by the selected model. A model flag without `!reasoning` keeps the configured reasoning +effort when the new model supports it, and otherwise uses the model's default. + +If a flag is invalid, the bot replies with a PR comment explaining why and does not start a session. +Flags work in PR conversation comments and inline review threads. Auto-reviews and review requests +always use the configured model. + +If the configured **Agent harness** cannot run the resolved model, the bot replies on the PR and +creates no session; it does not switch to OpenCode. Retry a mention with a compatible `!model`, +update the default model, or change the GitHub integration harness. + ### Inline Review Threads When you mention the bot in a PR review thread, Open-Inspect includes the file path and diff context @@ -100,6 +131,30 @@ when it needs context. Comment-triggered actions only run on pull requests. Mentions on ordinary GitHub issues are ignored. Comments from the bot itself are also ignored so the bot does not respond to its own output. +### Team Routing for Mentions + +Before creating a mention session, the bot asks the control plane for ownership using the numeric +GitHub repository ID and PR number. Routing follows this order: + +1. If the PR is linked to an existing Open-Inspect session, use that session's owning team (or + workspace ownership). This is only a routing hint: it neither resumes nor grants access to that + session, including a private one. +2. Otherwise, resolve the sender's linked GitHub identity and current teams with a repository or + installation grant covering the triggering repository. Exactly one eligible team wins. +3. If multiple teams qualify, use the sender's most recent session on that repository in a still + eligible team. If none resolves the ambiguity, use workspace ownership. Missing identity or no + eligible team also falls back to workspace ownership. + +A route lookup failure stops the request rather than guessing workspace ownership. GitHub trigger +gates still run, and session creation separately checks membership, team state, and repository +grants. A configured default environment is used only when ownership is compatible, it includes the +trigger repository, and the sender passes its repository checks; otherwise the target falls back to +the triggering repository. Workspace fallback can still be refused if the workspace requires team +ownership for new sessions. + +The runtime default for `TEAMS_ENFORCEMENT` remains `shadow`, not `on`; routing a session to a team +does not imply full team-read isolation in that mode. + --- ## What You See @@ -134,6 +189,7 @@ Open the web app and go to **Settings > Integrations > GitHub** to configure the | ------------------------ | ------------------------------------------------------------------------------------- | | Default model | Model used for GitHub-started sessions when a repository does not override it | | Default reasoning effort | Reasoning depth used with the selected default model | +| Agent harness | Harness that runs GitHub-started sessions (OpenCode default, or Claude Agent) | | Auto-review new PRs | Whether new non-draft PRs should be reviewed automatically | | Repository Scope | Whether the bot responds in all accessible repositories or only selected repositories | | Allowed Trigger Users | Who can trigger the bot from GitHub | @@ -149,17 +205,42 @@ empty, no one can trigger direct bot workflows for that scope. These settings do not gate GitHub event automations. Automations are matched separately by their repository, event type, enabled state, and trigger conditions. -### Models and Instructions +Event automations match the numeric GitHub repository ID, not just `owner/name`, and retain the +automation's saved owning team rather than using the mention sender's team. A team-owned automation +needs a current grant covering that repository at admission and launch. Losing the grant prevents a +run; restoring it allows a later event to run, not a replay of previously skipped events. Repository +renames retain identity through the numeric ID, while run targets use the event's current owner and +name. These checks are independent of the deprecated workspace auto-review setting. + +### Upgrading to Repository-ID Routing -| Setting | What it controls | -| --------------------------- | ------------------------------------------------------------------------- | -| Model and reasoning effort | Model and reasoning depth for GitHub-started sessions, when configured | -| Code Review Instructions | Extra guidance appended to PR review prompts | -| Comment Action Instructions | Extra guidance appended to `@mention` action prompts | -| Repository Overrides | Per-repository overrides for model, reasoning, instructions, and behavior | +Upgrade the GitHub bot and control plane together. The control plane rejects event envelopes without +a numeric repository ID, so an older bot's events are not routed. Automations saved before +repository IDs were recorded no longer match events by name; open each one, reselect its +repositories, and save to resolve their IDs. + +### Models and Instructions -Repository overrides take priority over global defaults for the repository they apply to. If neither -a repository override nor global default sets a model, sessions use the deployment default model. +| Setting | What it controls | +| --------------------------- | ---------------------------------------------------------------------------------- | +| Model and reasoning effort | Model and reasoning depth for GitHub-started sessions, when configured | +| Agent harness | Harness for GitHub-started sessions; incompatible models are refused | +| Code Review Instructions | Extra guidance appended to PR review prompts | +| Comment Action Instructions | Extra guidance appended to `@mention` action prompts | +| Repository Overrides | Per-repository overrides for model, harness, reasoning, instructions, and behavior | + +Repository overrides take priority over global defaults for the repository they apply to. Omitted +harness and model fields inherit individually from the global settings. If neither level sets a +harness, it is OpenCode; if neither sets a model, sessions use the deployment default model. A +[`!model` or `!reasoning` flag](#model-and-reasoning-overrides) at the start of a mention overrides +the corresponding model or effort setting for that one session, not the harness. + +Explicitly choosing a restricted harness such as **Claude Agent** requires a compatible default +model at the same settings level (global or repository), not an inherited model. The settings form +and control plane reject a missing model or an incompatible pair at that level. **Use system +default** is not offered when explicitly choosing Claude Agent. Save validation does not check the +merged global/repository pair: a model-only repository override can inherit an incompatible global +harness, in which case the bot refuses the launch. ### Commit Signing @@ -239,8 +320,9 @@ Important limitations: ### Bot Behavior -- Auto-review skips draft PRs and PRs opened by the GitHub App bot. Manual `@mention` triggers are - still evaluated through the normal repository and user gates. +- Auto-review skips draft PRs. An explicitly allowed App bot can trigger a comment-only self-review. + Manual `@mention` triggers still pass the repository and user gates, then team routing and + creation checks. - The bot ignores bot-authored comments, ordinary issue comments, and comments that do not mention the bot. - If the bot cannot load its GitHub integration settings, it fails closed and does not start direct @@ -263,7 +345,7 @@ Important limitations: Check that the GitHub App is installed on the repository and that the GitHub Bot worker is enabled. Then confirm the webhook URL, webhook secret, subscribed events, and `github_bot_username` in -[Complete GitHub Bot Setup](../GETTING_STARTED.md#step-7c-complete-github-bot-setup-if-using-github-bot). +[Complete GitHub Bot Setup](../GETTING_STARTED.md#github-bot-optional). Also check **Settings > Integrations > GitHub**. For direct GitHub Bot workflows, the repository may be outside the selected repository scope, or the triggering user may be outside the allowed user @@ -271,8 +353,9 @@ list. ### Auto-review did not run -Auto-review only runs for newly opened, non-draft PRs. It is skipped for draft PRs, bot-authored -PRs, disabled repositories, and users who are not allowed to trigger the bot. +The deprecated workspace auto-review only runs for newly opened, non-draft PRs. It is skipped for +draft PRs, disabled repositories, and users who are not allowed to trigger the bot. An App-authored +PR needs the App bot login explicitly allowed; its self-review can only comment. If a PR was converted from draft to ready for review, mention the bot in a PR comment instead. @@ -291,7 +374,8 @@ after the request was accepted. Open the Open-Inspect web app to inspect the ses ### The wrong model or instructions were used Check **Settings > Integrations > GitHub**. Repository overrides take priority over global defaults. -Changes apply to new GitHub-triggered sessions. +Changes apply to new GitHub-triggered sessions. A `!model` or `!reasoning` flag at the start of the +mention overrides both for that session. ### The bot is active in too many repositories diff --git a/docs/integrations/LINEAR.md b/docs/integrations/LINEAR.md index e897437ac4..719e3d2594 100644 --- a/docs/integrations/LINEAR.md +++ b/docs/integrations/LINEAR.md @@ -100,6 +100,40 @@ details. If the resolved repo is outside the selected Linear scope, Linear shows an error and no session starts. +### Team Bindings and Existing Mappings + +A team lead or workspace administrator binds an external Linear team ID to an Open-Inspect team in +that team's **Channels** tab. **Primary** marks the main binding for that provider; **Source** also +routes new sessions to the owning team. An external Linear team can be bound to only one +Open-Inspect team, and each Open-Inspect team has at most one primary binding per provider. Enter +the Linear team ID, not its name or issue-key prefix; this is separate from Slack channel discovery. + +Ownership bindings and legacy target mappings have different meanings. The KV configuration +`config:team-repos` maps a Linear team to repositories or environments; `config:project-repos` maps +a project to a target. Neither grants membership or repository access, nor assigns Open-Inspect team +ownership. The teams database migration creates an empty binding table and does not translate those +KV mappings. To adopt team ownership, explicitly create the Linear binding, add the required +Open-Inspect members and repository grants, and retain target mappings only for target selection. +Existing sessions are not reassigned by adding a binding. + +Linear's `unboundChannels` policy defaults to `workspace`, so an unbound Linear team starts +workspace-owned sessions. `reject` instead asks for a binding and creates no session. A binding +lookup failure also stops launch rather than falling back to workspace ownership. + +Repository, environment, and resolved integration-setting lookups carry the external Linear team +scope. Actor-bearing catalogs require team membership or workspace-admin access; creating a +team-owned session checks actual membership and target grants. Scoped lookup failures do not fall +back to a workspace-wide catalog. A mapping cannot make an out-of-scope target accessible. + +Some discovery and callback reads have no human actor. Those are narrowly authorized Linear-bot +service reads, still carrying the Linear team scope; they do not impersonate a team member or give +the app permission to start arbitrary sessions. Automation-created delegations use the installed app +user as the acting identity for session creation. + +`TEAMS_ENFORCEMENT` still defaults to `shadow`, not `on`. In particular, team-read denials can be +audited rather than blocked in that mode. Do not treat scoped callback reads as a guarantee of full +cross-team output isolation in shadow mode; private-session access remains enforced. + --- ## What Linear Shows @@ -115,6 +149,14 @@ starts. When a session starts, Linear receives a **View Session** link. If the agent opens a pull request, Linear receives a **Pull Request** link when the session finishes. +Before posting completion content, the bot verifies the issue's current Linear team. If it differs +from the recorded launch team, or cannot be verified, results are withheld. The bot reads session +events and artifacts without a human actor, scoped to that verified Linear team. A denied or failed +read produces a generic results-withheld error instead of session content or a PR link. Legacy +callbacks recover the launch team from a matching issue-session mapping when possible; otherwise +they use the verified current team for the scoped read. Enforcement of team visibility on those +reads depends on the rollout mode described above. + Open the web session for live output, logs, artifacts, and file changes. For a human-initiated session, Open-Inspect moves an unstarted issue to the team's lowest-position `started` workflow state only after the initial prompt reaches a live sandbox. It leaves automation-initiated, @@ -130,15 +172,16 @@ remain the responsibility of Linear's GitHub integration and the team's PR autom Open the web app and go to **Settings > Integrations > Linear** to configure the Linear Agent. -| Setting | What it controls | -| ------------------------------ | ----------------------------------------------------------------- | -| Default model and effort | Model and reasoning depth for Linear-started sessions | -| Repository Scope | Whether Linear can run in all accessible repos or selected repos | -| Issue Session Instructions | Extra guidance appended to Linear issue prompts | -| Allow user model preferences | Whether admin-managed user preferences can override the model | -| Allow model labels (`model:*`) | Whether Linear issue labels can choose the model | -| Tool progress activities | Whether Linear shows intermediate file and command activity | -| Repository Overrides | Per-repository defaults for model, reasoning, and Linear behavior | +| Setting | What it controls | +| ------------------------------ | ---------------------------------------------------------------- | +| Agent harness | OpenCode (default) or Claude Agent for new Linear sessions | +| Default model and effort | Model and reasoning depth for Linear-started sessions | +| Repository Scope | Whether Linear can run in all accessible repos or selected repos | +| Issue Session Instructions | Extra guidance appended to Linear issue prompts | +| Allow user model preferences | Whether admin-managed user preferences can override the model | +| Allow model labels (`model:*`) | Whether Linear issue labels can choose the model | +| Tool progress activities | Whether Linear shows intermediate file and command activity | +| Repository Overrides | Per-repository harness, model, reasoning, and Linear behavior | If no Linear settings are configured, all accessible repositories are in scope, user preferences and model labels are allowed, and tool progress is enabled. @@ -150,6 +193,23 @@ Model selection uses this priority, highest to lowest: 3. Repository override or global Linear default. 4. Deployment default model. +The configured **Agent harness** is preserved. If it cannot run the resolved model, the bot posts an +error activity in Linear and creates no coding session instead of switching to OpenCode. Claude +Agent runs Anthropic models only, so a `model:gpt-*` label is refused on that harness. Choose a +compatible `model:*` label, user preference, or default model, or change the Linear integration +harness, then delegate again. Labels and user preferences apply only when allowed. + +Omitted repository harness and model fields inherit individually from the global settings. If +neither level sets a harness, it is OpenCode. Explicitly choosing a restricted harness such as +**Claude Agent** requires a compatible default model at that same settings level (global or +repository), not an inherited model. The settings form and control plane reject a missing model or +an incompatible pair at that level. Save validation does not check the merged global/repository +pair; a cross-level mismatch is refused at launch. + +For successful launches, the "Creating coding session" activity names the harness. On Claude Agent, +Linear sessions follow the provider's **Automated authentication** policy and may use a connected +Claude account; see [Claude Agent](../CLAUDE_AGENT.md#linear-sessions). + Linear user preferences are currently admin/API-managed, not set from a self-service Linear screen. --- @@ -228,7 +288,10 @@ the rejected API request once. A reinstall is not normally required. ### The wrong model was used Check **Settings > Integrations > Linear**. Repository overrides, user preferences, and `model:*` -labels can affect model selection. Changes apply to new Linear-started sessions. +labels can affect model selection. A model the configured harness cannot run is refused with an +error activity and no coding session. Choose a compatible label, user preference, or default model, +or change the integration harness, then delegate again. Changes apply to new Linear-started +sessions. ### The wrong repository was used diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index e8cce93a63..15fb1c0c8e 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -5,10 +5,9 @@ the same Slack thread, set personal defaults in App Home, and ask agents to post that workflow is enabled. This guide is for people using the Slack integration day to day. If you are installing the Slack app -or deploying the worker, start with -[Getting Started](../GETTING_STARTED.md#step-4-create-slack-app-optional) and -[Complete Slack Setup](../GETTING_STARTED.md#step-7b-complete-slack-setup-if-using-slack). Optional -notification controls and safety notes are covered near the end. +or deploying the worker, start with [Getting Started](../GETTING_STARTED.md#create-the-slack-app) +and [Complete Slack Setup](../GETTING_STARTED.md#complete-slack-setup). Optional notification +controls and safety notes are covered near the end. --- @@ -20,7 +19,8 @@ notification controls and safety notes are covered near the end. @Open-Inspect fix the failing checkout tests in acme/web ``` 3. In a DM with the bot, send the request directly. You do not need to mention the bot in DMs. -4. If Open-Inspect asks which repository to use, choose one from the dropdown. +4. If Open-Inspect asks which target to use, choose a repository, environment, or **No repository** + from the dropdown. 5. Use **View Session** to open the full web session while the agent works. 6. Reply in the same Slack thread to continue the same session. @@ -35,7 +35,7 @@ notification controls and safety notes are covered near the end. | Continue a session | Reply in the same Slack thread | | Send images to the agent | Attach PNG, JPEG, WebP, or GIF images to an interactive request | | Forward a message | Share another Slack message with the bot; text, images, and source travel | -| Pick the repository | Let Open-Inspect infer it, or choose from a dropdown when it is unsure | +| Pick the session target | Use a repository, environment, or empty sandbox | | Set personal defaults | Use the Slack app's **Home** tab for model, reasoning effort, and branch | | Follow the result | Read the completion reply or open the full session with **View Session** | | Review generated media | Optionally attach charts, screenshots, and small recordings to the thread | @@ -71,13 +71,12 @@ repository name when the request could apply to more than one repo: @Open-Inspect update the billing docs in acme/api ``` -Open-Inspect chooses from repositories available to this Open-Inspect deployment, using the message, -Slack channel context, and recent thread context. It picks a repository in this order: if only one -repository is available, it uses that one; if your message contains a configured -[routing-rule keyword](#routing-rules), it routes to that keyword's repository; if an administrator -has associated the Slack channel with exactly one repository, that repository is used; otherwise it -infers the repository from your message. When the match is unclear, Open-Inspect asks you to choose -from candidate repositories in the Slack thread. +Open-Inspect chooses from repositories and environments available in the channel's scope, using the +message, Slack channel context, and recent thread context. A configured +[routing-rule keyword](#routing-rules) takes precedence, followed by a single channel association. +Otherwise the classifier chooses the best target for the request, including **No repository** when +the task does not require a codebase. When the match is unclear, Open-Inspect asks you to choose a +repository, environment, or **No repository** in the Slack thread. ### From a DM @@ -90,6 +89,43 @@ Can you investigate the flaky login test in acme/web? DMs do not need an `@mention`. If you include one anyway, Open-Inspect strips it before sending the request to the agent. +### Model and reasoning overrides + +Start a DM or `@mention` request with `!model` or `!reasoning` to override your App Home defaults: + +```text +@Open-Inspect !model anthropic/claude-sonnet-4-6 !reasoning max investigate the flaky test +``` + +Where you use the flags decides how long they last: + +- **On a request that starts a session**, they become that session's defaults. Every follow-up in + the thread keeps running on them until the thread ends, so you only have to pick the model once + per task. The "Starting work..." acknowledgement names the model when it is not your App Home + default. +- **On a follow-up in an existing session thread**, they apply to that one request and leave the + session's defaults alone. + +A running session's defaults cannot be changed. Naming your App Home model on a follow-up runs that +one request on it and leaves the session where it is; to go back to your defaults for good, start a +new session in a new thread. + +Both flags accept a space or colon before their value, such as `!model:openai/gpt-5.6-sol` and +`!reasoning:high`. Any flags must appear together at the start of the request. Models must be +enabled under **Settings > Models**, and reasoning values must be supported by the selected model. + +When your [agent harness](#default-model-and-agent-harness) is Claude Agent, a session-starting +`!model` that Claude Agent cannot run, such as `!model openai/gpt-5.4`, is refused and no session +starts. A follow-up stays on the thread's harness, so the control plane rejects a follow-up `!model` +that the harness cannot run. The bot replies in the thread: + +> Model "openai/gpt-5.4" cannot run on the Claude Agent harness. A thread keeps the harness its +> session started on. Reply without `!model`, or start a new thread to use that model. + +A `!reasoning`-only follow-up keeps the thread's session model, as a follow-up without flags does, +even if an administrator has since disabled that model. The thread's harness was chosen to run that +model, so the follow-up always runs. + To continue a session that started from a DM, reply in the Slack thread created for that DM request. Sending a new top-level DM is treated as a new request and may start repository selection again. @@ -100,15 +136,21 @@ interactive thread follow-up. You can include instructions with the images or se example, attach a screenshot and ask Open-Inspect to fix the visible error. Open-Inspect forwards at most six images per message, and each image must be no larger than 10 MiB. -If Open-Inspect asks you to choose a repository or environment, make the selection normally. The bot -retrieves the original message's images after you choose and forwards them with the saved request. -If only some images can be read, the remaining images and any message text still reach the agent, -and the bot posts a warning in the thread. If an image-only request loses every image, no empty -session or follow-up is sent. +If Open-Inspect asks you to choose a target, make the selection normally. The bot retrieves the +original message's images after you choose and forwards them with the saved request. If only some +images can be read, the remaining images and any message text still reach the agent, and the bot +posts a warning in the thread. If an image-only request loses every image, no empty session or +follow-up is sent. This feature requires the Slack app's `files:read` bot scope and a reinstall after adding the scope. Remote files hosted outside Slack and non-image attachments are not forwarded. +Interactive requests also retain files from the recent Slack thread context. A supported image on an +earlier selected message is forwarded through the same protected attachment path; file-only messages +remain visible through URL-free metadata in the message's JSON context record even when their file +type is unsupported or the image cannot be retrieved. Images on the current request take priority +within the six-image prompt limit, followed by deduplicated images from earlier context. + ### With forwarded messages Forward (share) another Slack message to a DM, to a channel request that `@mentions` the bot, or to @@ -127,15 +169,19 @@ Forward several messages at once and each is quoted separately, up to ten per re message's text is truncated at 4,000 characters. Link previews are skipped, since the message text already carries the link. -### Repository dropdowns +### Target dropdowns + +Target dropdowns are tied to the pending Slack thread, not to a personal GitHub repository list. +They show accessible repositories and environments plus **No repository**, which starts with an +empty sandbox. Open-Inspect keeps the original request for one hour; after a target is selected, the +session starts with that original request and thread context. -Repository dropdowns are tied to the pending Slack thread, not to a personal GitHub repository list. -They show candidate repositories that the Open-Inspect deployment can access. Open-Inspect keeps the -original request for one hour; after a repository is selected, the session starts with that original -request and thread context. +The planned model is rechecked before launch. If it was disabled while the request was pending, the +bot refuses to start a session rather than substituting another model. Send a new request with an +enabled model. -In shared channels, the original requester should choose the repository. If the dropdown has -expired, send the request again and include the repository name, such as `owner/repo`. +In shared channels, only the original requester can choose the target. If the dropdown has expired, +send the request again and name the repository, environment, or that no repository is needed. ### Routing rules @@ -173,6 +219,63 @@ conventions. They apply to new sessions only (thread follow-ups continue with th context), are limited to 10,000 characters, and mirror the Linear integration's **Issue Session Instructions**. +### Default model and agent harness + +Administrators set two workspace-wide defaults for new Slack sessions under **Settings > +Integrations > Slack**: + +| Setting | What it controls | +| ------------- | ----------------------------------------------------------------------------------- | +| Default model | The model for users who have not chosen one in App Home; unset uses `DEFAULT_MODEL` | +| Agent harness | OpenCode (default) or Claude Agent for users who have not chosen one in App Home | + +A new session runs on the requester's App Home **Agent harness**, or on the workspace **Agent +harness** when the requester has not chosen one. Model selection for a new session uses this +priority, highest to lowest: + +1. `!model` and `!reasoning` flags on the request that starts the session. +2. The requester's App Home model and reasoning effort. +3. The Slack **Default model**. +4. Deployment default model (`DEFAULT_MODEL`). + +The effective model must be enabled under **Settings > Models**. If the App Home model, Slack +**Default model**, or deployment/system default used by the request is disabled, the bot refuses to +start a new session. It never substitutes a lower-priority default or the first enabled model. +Re-enable the model or update the applicable preference or default. An enabled App Home model or +leading `!model` override still works when a lower-priority default is disabled, provided the +session's harness can run it. Existing-thread follow-ups without `!model` keep the session's model, +even if it has since been disabled. + +If the session's harness cannot run the resolved model, the bot starts no session. Claude Agent runs +Anthropic models only, so an OpenAI App Home model or `!model openai/gpt-5.4` is refused on Claude +Agent. After its "Starting work..." message, the bot replies in the thread: + +> Model "openai/gpt-5.4" cannot run on the Claude Agent harness. Start your request with `!model` +> and a model Claude Agent can run, or change your model or agent harness in the Slack app's Home +> tab. + +The settings form lists only models the selected harness can run and rejects saving a harness with a +model it cannot run. When the workspace **Agent harness** is Claude Agent, a **Default model** is +required: the form disables **Save** and says "Choose a default model Claude Agent can run." until +one is chosen, and the control plane rejects saving Claude Agent without one. "Use system default" +is not offered for Claude Agent, because the settings page cannot see the deployment default model, +which may be one Claude Agent cannot run. + +A harness change applies to new sessions. Each thread keeps the harness its session started on. If +the bot cannot read the Slack settings, App Home may show OpenCode as the workspace-harness display +fallback without changing the user's saved App Home harness. This does not permit a new launch: new +session requests are refused until Slack settings are available. + +Slack sessions are unattended, so on Claude Agent they follow the default Claude account's +**Automated authentication** policy and may use a connected Claude account. This applies when a user +picks Claude Agent in App Home too, even if the workspace harness is OpenCode. Set **Automated +authentication** to **No account (API key)** to keep Slack sessions on `ANTHROPIC_API_KEY`. The +session also loads the repository's `CLAUDE.md` and `.claude/` settings and hooks, and code the +agent runs inherits the Anthropic credential, so let Slack reach only repositories whose `.claude/` +configuration you trust. A repository's first Claude Agent sessions can start without its prebuilt +image if that image predates the Claude image floor. See +[Claude Agent](../CLAUDE_AGENT.md#slack-sessions). + --- ## Threaded Conversations @@ -186,10 +289,22 @@ thread, no mention is needed. Watched-channel automation threads are text-only, [Channel Message Triggers](#channel-message-triggers). Open-Inspect keeps the Slack thread connected to the session for about 7 days. If you reply after -that mapping expires, or if you reply outside the thread, the bot may start repository selection -again and create a new session. - -For follow-ups, Open-Inspect includes recent thread context with the new prompt. It also adds an +that mapping expires, or if you reply outside the thread, the bot may start target selection again +and create a new session. + +An existing mapping is not replaced merely because a follow-up fails. If the session is missing or +no longer publishable in this channel, the bot reports that the thread is closed; start a new +request in a new thread. A follow-up 404 triggers a separate publication check: the mapping is +marked closed only when that check confirms denial. An actor-specific 403 leaves the mapping usable +by other authorized people, and an actor-concealed 404 does not close it for everyone when channel +publication is still allowed. Binding changes are checked on each mapped follow-up. A closed mapping +can reopen if its original team binding matches again and publication is allowed. + +For follow-ups, Open-Inspect includes up to ten recent thread messages posted after the preceding +prompt and strictly before the new request. Replies that arrive while Slack history is being fetched +are not exposed to the earlier turn; they remain eligible for a later follow-up. Earlier messages +are encoded as untrusted JSON records with speaker and timestamp provenance; file-only messages keep +URL-free metadata, and supported images are forwarded as described above. Open-Inspect also adds an eyes reaction while the follow-up is being processed, then removes it when the completion reply is posted. @@ -197,19 +312,18 @@ posted. ## What Gets Posted Back -When a request is accepted, Open-Inspect posts a working reply in the Slack thread and then adds a -link to the web session once it exists. For confident repository matches, the working reply may -include a **View Session** button. Every session also gets a session-started reply with a **View -progress** link. +When a request is accepted, Open-Inspect posts a working reply in the Slack thread and adds a **View +Session** button once the web session exists. The web session is the best place to watch live output, inspect files, or take over. When the agent finishes, Slack receives a completion reply with: -- The agent's final response, shortened if it is too long for Slack +- The agent's final response, rendered as Markdown (headings, lists, links, tables) and shortened if + it is too long for Slack. Broadcast mentions such as `@here` and `@channel` are removed. - Created artifacts such as pull requests or branches - A few key tool actions, such as edits or commands -- The final status, model, repository, and reasoning effort when available +- The final status, model, session target, and reasoning effort when available - A **View Session** button If the agent created a manual-PR branch and no PR artifact is already present, Slack may also show a @@ -225,13 +339,20 @@ Slack sessions. | Setting | What it controls | | ---------------- | ---------------------------------------------------------------------------- | +| Agent harness | Workspace default, OpenCode, or Claude Agent for your new Slack sessions | | Model | The model used when you start a new session from Slack | | Reasoning effort | The reasoning depth, shown for models that support reasoning effort controls | | Branch | A global branch override for new Slack sessions | | Branch by repo | A branch override for one repository, shown when repositories are available | -The selector normally uses models enabled in **Settings > Models** in the web app. If Slack cannot -load that list, it falls back to the default enabled models. +The selector uses models enabled in **Settings > Models** in the web app. If Slack cannot load that +list, Home shows a temporary-unavailability notice and preserves your configured model and reasoning +without treating the model as disabled. It lists only models your agent harness can run. If +compatible enabled models are available but your current model is disabled or incompatible, the +selector shows "Choose a model" with no selection and a refusal note. If no enabled model can run on +the selected harness, Home shows a message instead of a selector, asking you to choose another +harness or have an admin enable a compatible model. The "Currently using" summary names your model +and agent harness, or marks the model as needing replacement. Branch preference priority is: @@ -240,15 +361,18 @@ Branch preference priority is: 3. Repository default branch These preferences are per Slack user. They affect new Slack sessions; follow-ups in an existing -Slack thread continue the existing session. +Slack thread continue the existing session. A leading `!model` or `!reasoning` flag overrides the +corresponding setting for the session it starts, or for a single follow-up request, without changing +these preferences. Without an App Home model or agent harness, new sessions use the workspace +[default model and agent harness](#default-model-and-agent-harness). --- ## Optional Agent Notifications -Interactive Slack sessions (DMs and `@mentions`) always get their normal thread replies and -completion messages. Agent notifications are separate: they let an agent post an extra message to a -Slack channel when you explicitly ask for it: +Interactive Slack sessions (DMs and `@mentions`) get normal thread replies and completion messages +only while publication is allowed. Agent notifications are separate: they let an agent post an extra +message to a Slack channel when you explicitly ask for it: ```text When you finish, post a short summary to #eng-updates. @@ -262,9 +386,12 @@ To use this workflow: 4. Optional: add repository overrides to inherit, force on, or force off agent notifications for specific repositories. -Channel membership controls where these extra posts can go. Invite the bot to a channel to make it -available; remove it from a channel to remove access. Slack may still reject missing, archived, -inaccessible, or rate-limited targets. +Bot membership is necessary, but is not the only publication check. Private-session output is +refused, as is output to a channel bound to a different owning team, even for a workspace-visible +session. These checks also cover managed completions and generated media. They use current session +visibility and channel bindings, not just the binding at launch. An unbound destination is not +automatically a cross-team refusal; do not treat channel bindings as an outbound allowlist. Slack +may still reject missing, archived, inaccessible, or rate-limited targets. Changes apply to new sessions. If you turn notifications on and an existing session cannot post to Slack, start a new session. Turning notifications off blocks future notification attempts. @@ -292,11 +419,13 @@ in a watched channel — without `@mentioning` the bot. This is distinct from th `@mention` flow: it is driven by [automations](../AUTOMATIONS.md#slack-message-triggers) with keyword, substring, or regex conditions. -Slack Message automations ingest message text only. A message that carries an attachment does start -an automation, but on its text alone — the attachment itself is not forwarded, so an image-only -message with no text starts nothing. Attachments on automation thread replies are likewise not -forwarded to the session, and the body of a forwarded message is not read. Use an interactive DM or -`@mention` when the agent needs an image or a forwarded message. +Slack Message automations ingest triggering-message text only. A triggering message that carries an +attachment starts an automation on its text alone — the attachment itself is not forwarded, so an +image-only trigger starts nothing. Attachments on automation thread replies are likewise not sent as +bytes, and the body of a forwarded message is not read. When an earlier message selected for thread +context has files, URL-free file metadata is retained in that context so the message does not +disappear; supported images are explicitly marked as not forwarded by automations. Use an +interactive DM or `@mention` when the agent needs image bytes or a forwarded message. When the triggering message is a **reply**, the agent also receives the thread it was posted in, so it can read the reply in context rather than as an isolated sentence. The thread is read only once a @@ -304,12 +433,13 @@ run has actually been admitted — never for messages that match no automation, continue an existing session, or for firings dropped as concurrent or duplicate — and once per message however many automations match it. Top-level messages have no thread to read. -The context contains up to 20 earlier messages total; on long threads, the opening message is -preserved alongside the most recent replies. Each message is truncated to 1,024 characters, and its -speaker record identifies people, apps, and the bot's own earlier turns without relying on a display -name alone. It is passed as JSON and labelled untrusted: Slack text is written by people who may not -be asking the agent anything, so it is presented as a record of the conversation rather than as -instructions. If Slack cannot be read, the run starts with no thread history rather than failing. +The context contains up to 20 messages posted strictly before the trigger; on long threads, the +opening message is preserved alongside the most recent replies. Each message is truncated to 1,024 +characters, and its record includes the Slack timestamp plus a speaker identity for people, apps, +and the bot's own earlier turns without relying on a display name alone. It is passed as JSON and +labelled untrusted: Slack text is written by people who may not be asking the agent anything, so it +is presented as a record of the conversation rather than as instructions. If Slack cannot be read, +the run starts with no thread history rather than failing. ### Slack app setup @@ -342,21 +472,29 @@ condition to filter by content. See automation's instructions; without an explicit instruction it will answer every message it is woken for. A run that opened a pull request or produced other artifacts always posts, and interactive `@mention` sessions never decline — a person is waiting on a visible answer there. -- Every reply in a thread continues the same session — during the run and after it finishes — for up - to 7 days after the thread's first trigger, like replying in an `@mention` thread. The reply is - routed to that session as a follow-up prompt (re-spawned from a snapshot if it had gone idle), - gets its own 👀 reaction and in-thread response, and does **not** need to match the trigger's text - condition — conditions gate new runs, not replies that continue a thread. A reply more than 7 days - after the first trigger starts a fresh run. +- Authorized replies in a thread continue the same session, while the run starts, during it, and + after it finishes, until the session has been idle for 7 days, like replying in an `@mention` + thread. The reply is routed to that session as a follow-up prompt (re-spawned from a snapshot if + it had gone idle; held until the run's own first prompt while the run is starting), gets its own + 👀 reaction and in-thread response, and does **not** need to match the trigger's text condition — + conditions gate new runs, not replies that continue a thread. A reply after 7 idle days starts a + fresh run. + +The automation scheduler checks each reply author's session collaboration access; a rejected author +does not start a replacement run for that automation. This is separate from interactive +mapped-thread handling: if no steerable session exists or enqueueing fails, the scheduler may +re-evaluate the reply as a new trigger. Runs retain the automation's saved owning team, not the +channel's interactive routing choice. Publication still checks current visibility and channel +bindings, so admission does not guarantee a reply can be posted. ### Threat model Channel triggers widen who can start a coding session, so weigh the following before configuring them: -- **Any member of a watched channel can trigger a run** simply by posting a matching message. Treat - every watched channel as a list of people authorized to start sessions against the automation's - repository. +- **Any member of a watched channel can supply a matching trigger** unless conditions restrict them. + Treat watched channels as sources of untrusted requests to the automation's executor. Execution + membership and grant checks do not make the triggering message trustworthy. - **Prefer an allowlist.** Add a **Slack User** condition (`include`) so only specific people can trigger the automation, and keep watched channels small and trusted. - **Message text reaches the agent.** The triggering message becomes part of the prompt. Scope the @@ -372,16 +510,39 @@ them: These notes are most useful for workspace admins deciding where the Slack bot should be available. +### Team Channel Bindings + +A team lead or workspace administrator can bind a Slack channel in the team's **Channels** tab. +**Primary** marks the team's main Slack binding; **Source** adds another channel that routes new +interactive sessions to the same team. Both kinds determine ownership, not a repository or a default +notification destination. Each channel can belong to only one team, and each team can have only one +primary binding per provider. The bot must already be in the channel; externally shared Slack +Connect channels cannot be bound. + +The Slack integration's `unboundChannels` policy is `workspace` by default: an unbound channel +starts workspace-owned sessions. With `reject`, new interactive requests in unbound channels are +refused until a binding is added. Unbound DMs remain workspace-scoped under either policy. A failed +binding lookup stops the request rather than silently falling back to workspace ownership. + +Classification and target dropdowns use a live channel- and actor-scoped catalog. Bound-team +catalogs require team membership or workspace-admin access and are filtered by repository grants and +eligible environments. Starting a team-owned session checks the actor's actual team membership and +grants for the selected target; Slack channel membership alone does not enroll that person in the +Open-Inspect team. Picker submissions recheck the binding, so an old picker cannot transfer a +request to another team. Repository routing rules and channel associations select a target within +this scope; they do not grant access. + +`TEAMS_ENFORCEMENT` still defaults to `shadow`, not `on`. Do not assume full team-read isolation in +that mode. Team creation checks, scoped catalogs, live Slack follow-up channel checks, private +access, and Slack publication gates are not a promise that every team read is enforced. + - Slack bot tokens stay server-side. They are not sent to sandboxes. - Slack requests are verified before Open-Inspect acts on them. -- Slack-created sessions use deployment-level repository access. The repositories shown in Slack are - the repositories accessible to the configured GitHub App or SCM installation, not a per-Slack-user - GitHub permission list. -- Slack identity linking is best-effort and is not used to approve repository access. To restrict - what Slack sessions can touch, limit the GitHub App installation to selected repositories and - invite the Slack bot only into trusted channels. +- The source-control installation is the outer repository boundary, not a per-Slack-user GitHub + permission list. Team bindings, memberships, and repository grants further constrain team work. +- Invite the bot only into trusted channels; identity linking does not itself grant team membership. - Bot messages are ignored so the Slack bot does not respond to itself. -- Agent notifications use Slack channel membership as the access boundary. +- Agent notifications require bot membership and the publication checks described above. - Accepted notification text is sanitized and shortened to fit Slack block limits; extremely large raw inputs are rejected. @@ -396,17 +557,17 @@ mentions the bot. An ordinary channel message only starts a session when it matc Slack Message automation; verify its watched channel and conditions. If setup was just changed, confirm the Slack app event subscriptions and interactivity URLs in -[Complete Slack Setup](../GETTING_STARTED.md#step-7b-complete-slack-setup-if-using-slack). +[Complete Slack Setup](../GETTING_STARTED.md#complete-slack-setup). ### DMs do not start sessions The Slack app needs the direct message event subscription configured. Once that is set up, send the bot a plain DM with your request. No `@mention` is required. -### Open-Inspect asks which repository to use +### Open-Inspect asks which target to use -Choose a repository from the dropdown, or resend the request with the repository name included. The -dropdown expires after one hour. +Choose a repository, environment, or **No repository** from the dropdown, or resend the request with +the intended target included. The dropdown expires after one hour. ### A follow-up started a new session @@ -433,9 +594,20 @@ content when Slack omits it from the `app_mention` event, and images inside a fo ### The wrong model or branch was used -Open the Slack app's **Home** tab and check your model, reasoning effort, and branch preferences. -Repository-specific branch overrides take priority over the global branch override. Preference -changes apply to new Slack sessions. +Open the Slack app's **Home** tab and check your agent harness, model, reasoning effort, and branch +preferences. Repository-specific branch overrides take priority over the global branch override. +Without an App Home model or agent harness, sessions use the **Default model** and **Agent harness** +in **Settings > Integrations > Slack**, then the deployment default model. Preference changes apply +to new Slack sessions. + +### Slack refused a request because of its model + +Claude Agent runs Anthropic models only. When your agent harness is Claude Agent, a non-Anthropic +model from `!model`, App Home, or the defaults is refused with "Model ... cannot run on the Claude +Agent harness." and no session starts. Start the request with `!model` and an Anthropic model, or +change your model or agent harness in the Slack app's **Home** tab. Your harness is your App Home +choice, else the workspace **Agent harness**. In an existing thread, the session keeps the harness +it started on, so reply without `!model` or start a new thread. ### The agent could not post a Slack notification diff --git a/docs/plans/final-sandbox-snapshot.md b/docs/plans/final-sandbox-snapshot.md new file mode 100644 index 0000000000..5fbfb713d9 --- /dev/null +++ b/docs/plans/final-sandbox-snapshot.md @@ -0,0 +1,471 @@ +# Sandbox checkpoint and graceful shutdown before provider expiry + +- **Status:** Implemented locally; automated verification complete, provider-backed release canaries + required. +- **Date:** 2026-09-19 +- **Issue:** + [#1935 — Modal snapshot lost data](https://github.com/ColeMurray/background-agents/issues/1935) +- **Researched baseline:** public `main`, commit `222f1c002f1ba9ff7624090e1424b7e556b772f6`. + +## 1. Decision summary + +The Control Plane will schedule a **checkpoint-and-shutdown operation** before a Sandbox's actual +provider deadline. The operation closes admission to the current generation, halts an active prompt, +preserves the filesystem through the provider's supported mechanism, durably records the recovery +point, and retires the old running Sandbox. It is independent of browser presence and per-prompt +timeouts. + +Users configure **Final snapshot buffer** in the existing Sandbox settings UI. This is the entire +time reserved before expiry for stopping execution, creating or confirming the recovery point, and +retiring the Sandbox—not just the duration of the snapshot API call. + +“Final snapshot” is the product label. **Graceful shutdown** can produce two kinds of recovery +point: + +- An independent filesystem snapshot for Modal and Vercel. +- A verified retained, stopped/paused Sandbox for E2B and Daytona, where this is the existing + persistence contract. These are not represented as fabricated image IDs. + +Use Modal's existing explicit filesystem snapshot API. Do not enable experimental exit snapshots, +experimental memory snapshots, or provider-managed automatic snapshotting as the coordinator. Modal +documents its [exit snapshot API as experimental](https://modal.com/docs/sdk/js/latest/Sandbox); +that API is not a dependency of this design. + +For Daytona, preserve its current absence of a fixed provider lifetime. Do not introduce an +application-enforced lifetime merely to make the providers look alike. Its existing inactivity stop +uses the same stop-and-preserve contract; a pre-expiry alarm applies if its provider contract later +supplies a finite deadline. This is an explicit product decision, not an unsupported-provider skip. + +## 2. Problem, evidence, and scope + +The issue reports work missing after restoration. The supplied logs show an execution-complete +snapshot starting around the provider timeout and failing while the Sandbox shut down. Later +comments report recovering data from another logged snapshot ID. These reports motivate preserving +state before expiry; they do not establish that every missing-context incident has this cause. +[Issue and discussion](https://github.com/ColeMurray/background-agents/issues/1935). + +Current source independently confirms the scheduling gap: + +| Current behavior | Consequence | Evidence at the researched commit | +| ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Lifecycle alarms check connecting, heartbeat, boot, and inactivity limits, but not provider lifetime. | A healthy, busy Sandbox can reach provider expiry without a checkpoint-and-shutdown operation. | [Lifecycle alarm handling](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/lifecycle/manager.ts#L1485) | +| Browser clients can extend inactivity. | Inactivity is not a provider-expiry safety mechanism. | [Inactivity decisions](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/lifecycle/decisions.ts#L460) | +| The runtime subtracts a reserve from each prompt's budget; each prompt receives a fresh clock. | The reserve does not track accumulated Sandbox uptime. | [Prompt budgets](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/sandbox-runtime/src/sandbox_runtime/prompt_budgets.py#L64), [prompt receipt](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/sandbox-runtime/src/sandbox_runtime/bridge.py#L863) | +| `triggerSnapshot()` returns `void`, logs errors, and restores the previous status. | Callers cannot distinguish preserved, failed, skipped, and unknown outcomes. | [Snapshot implementation](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/lifecycle/manager.ts#L1268) | +| Inactivity can announce “snapshot saved” without a successful result. | Users can receive an incorrect assurance that state was saved. | [Inactivity stop](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/lifecycle/manager.ts#L1649) | +| Execution completion launches a background snapshot and pumps the prompt queue. | Final stopping needs its own durable admission fence and snapshot ownership. | [Execution handler](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/session/sandbox-events/execution.handler.ts#L124) | +| `stop` cancels the prompt task without awaiting its completion; `snapshot` only emits `snapshot_ready`. | Neither command establishes that an active prompt and its tools have stopped. | [Bridge commands](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/sandbox-runtime/src/sandbox_runtime/bridge.py#L1017) | +| `snapshot_saved`, `sandbox_warning`, and `sandbox_restored` do not produce durable web recovery state. | A new broadcast alone will not solve visibility after reload. | [Session reducer](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/web/src/lib/session-socket/reducer.ts#L338) | + +### Goals + +1. Begin checkpoint and shutdown early enough to finish before a known provider cutoff under the + tested operating budget. +2. Halt the active prompt before the normal final capture, and prevent another prompt or managed + workspace mutation from starting on the retiring generation. +3. Preserve saved workspace files, including uncommitted/untracked files and sandbox-local agent + state covered by that provider's persistence mechanism, across all four supported providers. +4. Survive Control Plane restart, duplicate alarms, reconnects, and ambiguous provider responses. +5. Never report checkpoint-and-shutdown success or silently restore an older recovery point after + that operation failed. +6. Make the buffer configurable through existing global, repository, and environment settings. + +### Non-goals and limits + +- Reconstructing missing model conversation history from the Session Event Stream: this is separate + from filesystem recovery and + [issue #997](https://github.com/ColeMurray/background-agents/issues/997). +- Cross-provider artifact migration, arbitrary snapshot history/browsing, periodic backup policy, + Git auto-commits, or changing ordinary connected-client inactivity policy. +- Preserving unsaved editor buffers, arbitrary process memory, external database transactions, or + already-issued external side effects. An interrupted prompt is not safe to replay automatically. +- Promising losslessness during a provider outage, unexpected host loss, an unbounded filesystem + capture, or a Control Plane outage longer than the reserve. The design gives a measurable + pre-expiry protocol and an honest degraded outcome, not a backup SLA. +- Bypassing the runtime compatibility floor. An incompatible retained image must be reported as + recovery-required, not discarded and replaced with a fresh checkout without notice. +- Stopping unrelated editors, terminals, VNC, or background services to establish whole-workspace + quiescence. This feature confirms active prompt/tool stopping, not arbitrary application + consistency. +- A new successful-resume product workflow, provider-side result journals, or a separate + lease-renewal and clock-synchronization protocol. + +## 3. Provider behavior and required adaptations + +The common policy must use provider receipts, not a provider-name switch scattered through Session +code or the existing booleans alone. + +| Provider | Current implementation | Checkpoint and shutdown | Deadline source | +| ----------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Modal** | Two-hour default passed to create; explicit filesystem snapshot; image restore. No session explicit-stop capability. | Stop prompt → explicit filesystem snapshot → commit ready image → authenticated provider termination. Add a narrow stop endpoint; runtime shutdown remains a courtesy, not proof of termination. | Provider timing if available; otherwise a conservative timestamp immediately **before** provider create plus its accepted duration. Return it from both create and restore. | +| **Vercel** | Adapter caps lifetime at 45 minutes. Snapshot/restore supported. | Stop prompt → snapshot, which itself stops the source → commit ready image and stopped result. Do not wait for an acknowledgement from a runtime already stopped by snapshotting. | Normalize provider `session.createdAt` and accepted `session.timeout`; account for the adapter cap. | +| **E2B** | Session snapshots intentionally unsupported. Stop pauses; resume reconnects the retained object and can change its timeout. | Stop prompt → explicit pause → verify paused/retained → commit retained-object receipt. Never use the destructive kill branch as successful state capture. | Parse provider detail `endAt` after create, connect, and timeout mutation. A reconnect is not itself evidence of a renewed lease. | +| **Daytona** | No hard-TTL capability; inactivity auto-stop/archive; stop/start retains filesystem. | At an existing stop boundary: stop prompt if necessary → stop → verify stopped/retained → commit retained-object receipt. Do not use the respawn/delete branch. | Explicit `none` today. `timeoutSeconds` used for preview access is not a Sandbox deadline. | + +Current adapter evidence: +[Modal](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/providers/modal-provider.ts#L89), +[Vercel](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/providers/vercel/provider.ts#L57), +[E2B](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/providers/e2b-provider.ts#L176), +[Daytona](https://github.com/ColeMurray/background-agents/blob/222f1c002f1ba9ff7624090e1424b7e556b772f6/packages/control-plane/src/sandbox/providers/daytona-provider.ts#L85). + +Provider documentation confirms that +[Vercel snapshots stop the source](https://vercel.com/docs/sandbox/concepts/snapshots), +[E2B pause preserves a resumable Sandbox](https://docs.e2b.dev/sandbox/persistence), and +[Daytona stop/start preserves persistent filesystem state](https://www.daytona.io/docs/en/persistence/). +These mechanisms have different semantics but satisfy the common recovery contract when verified. + +Snapshot expiry is also independent of source Sandbox expiry; this feature does not add retention +management. The baseline pins Modal 1.4.3; newer documentation changes the filesystem-snapshot +default retention. Do not propagate the existing “persists indefinitely” comments as a general +guarantee. [Modal retention](https://modal.com/docs/guide/sandbox-snapshots). + +## 4. Configuration + +Add optional `finalSnapshotBufferMs` to existing `SandboxSettings`, not a separate settings service. +The web UI labels it **Final snapshot buffer (minutes)**; storage and TypeScript use milliseconds, +provider/runtime calls convert explicitly to seconds where required. + +- Default: **600,000 ms (10 minutes)**. +- Minimum: **300,000 ms (5 minutes)**, in whole seconds. +- An explicitly configured buffer must be smaller than an explicitly configured lifetime. +- Timing validation uses the inherited buffer or `DEFAULT_FINAL_SNAPSHOT_BUFFER_MS` when no buffer + is explicitly configured. There is no exemption for unchanged or previously stored timeouts. + Stored settings use the existing invalid-value normalization; writes reject invalid timing. +- Resolve through the existing global → primary-repository → environment settings composition. + Repository-less sessions use the existing global/environment path. +- Child sessions inherit the parent's resolved lifetime/buffer pair rather than accidentally + combining a parent's lifetime with a different repository's reserve. +- Session settings are captured at launch. Editing dashboard defaults does not renew an existing + provider lease or change a generation already shutting down. +- Do not silently shrink the safety reserve to fit a short lifetime: a new generation already inside + its drain boundary begins graceful shutdown without dispatching a prompt. If the remaining phase + budgets do not fit, surface failure. Users should select a lifetime longer than the buffer. +- A provider may cap the requested lifetime. Scheduling always uses its returned effective deadline, + not the dashboard request. This matters for Vercel's current adapter cap. +- The setting does not create a fixed lifetime for Daytona. Its existing inactivity stop uses the + same checkpoint-and-shutdown procedure. + +## 5. Deadlines and durable ownership + +Reuse the existing generation identity: + +```ts +type SandboxGeneration = { sandboxId: string; createdAt: number }; + +type SandboxLifetime = + | { + kind: "finite"; + expiresAtMs: number; + observedAtMs: number; + source: "provider" | "conservative_start_bound"; + } + | { kind: "none"; observedAtMs: number } + | { kind: "unknown"; observedAtMs: number; reason: string }; +``` + +Generation creation time is an ownership fence, not a claim about provider expiry. In-place resume +receives a new generation and a new lifetime receipt. Missing expiry is not equivalent to a +documented absence of fixed expiry. + +For a finite deadline `D`, start draining at `D - finalSnapshotBufferMs`. Browser presence, +heartbeats, and prompt completion cannot extend that boundary. + +The phase budgets have fixed upper bounds; capture time shrinks to fit the remaining buffer: + +| Phase | Maximum budget | +| -------------------------------------- | -------------- | +| Confirm active prompt/tool stop | 60 seconds | +| Capture or retained-state confirmation | 300 seconds | +| Confirm separate source retirement | 30 seconds | +| Reserve before the hard cutoff | 30 seconds | + +At entry, clamp each absolute phase deadline to the provider cutoff. A late alarm cannot restart a +full relative budget beyond `D`. At the five-minute floor, reserve 60 seconds for stopping, up to +180 seconds for capture, 30 seconds for retirement, and 30 seconds of safety margin. Larger buffers +retain up to 300 seconds for capture; the ten-minute default is unchanged. A late alarm further +reduces the capture window rather than moving the provider cutoff. Provider calls receive both an +absolute deadline and an abort signal; the coordinator also bounds its wait. An HTTP cancellation is +not proof that a remote side effect rolled back. + +Use the existing earliest-deadline alarm scheduler, shared by Cloudflare and Node hosts. Check +shutdown before generic execution/heartbeat cleanup and before slow index projection I/O, and +reassert the finite boundary on every earlier alarm. Independently check the absolute boundary in +prompt admission, including after asynchronous credential lookup and immediately before +claiming/sending a prompt. + +Persist one validated `sandbox_preservation` singleton in the Session SQLite database: + +- Generation, provider object locator, lifetime kind and provenance, expiry and drain boundary. +- Runtime protocol support and matching generation acknowledgement. +- Phase, operation ID, interrupted message ID, durable continuation pause, absolute phase deadlines. +- Ordinary-checkpoint in-flight marker. +- Latest verified recovery receipt and safe failure description. + +A receipt contains provider ownership, artifact locator, kind (`snapshot` or `retained`), runtime +version, and last successful save time. Do not expose locators or credentials through the public +Session projection. Retained E2B/Daytona objects are mutable on resume: their timestamps are not +promises of an immutable historical rollback point. + +The durable phases are: + +```text +running → draining → prepared → capturing → retiring → saved + \________ failures / uncertain outcomes ______/ + failed / unknown +``` + +No separate lease epoch, distributed service, provider-side journal, or clock-synchronization +protocol is required. + +## 6. Runtime preparation and prompt settlement + +New runtimes advertise `preservationProtocolVersion: 1` in `ready`. Before dispatch, the Control +Plane sends `sandbox_generation`; the runtime returns critical/replayable `sandbox_generation_ready` +with the identical generation. A same-generation reconnect does not clear the runtime's shutdown +fence. Only a new authenticated generation may clear it. This is essential when E2B resumes frozen +process memory. + +At the boundary: + +1. In one synchronous Session transaction, claim an operation, persist `draining`, and terminalize + the current processing message through the existing message-failure machinery. At hard expiry its + reason is `sandbox_lifetime_expiring`. A natural completion committed before that transaction + remains completed. Never infer order from runtime timestamps. +2. Publish the interrupted result and reconcile status/callbacks asynchronously. These awaits do not + delay the fence. Pending messages stay pending; the interrupted prompt is never automatically + replayed. +3. Send the existing authenticated sandbox socket: + ```ts + const command = { + type: "prepare_preservation", + operationId, + generation, + messageId, // optional for shutdown between prompts + stopByMs, + }; + ``` +4. The bridge closes prompt/push/managed-refresh admission before awaiting anything. It contains the + active harness execution under that single deadline, cancels/joins the prompt task, and persists + its rotated sandbox-local session identifier. + - **Claude:** request interrupt, then await the SDK-owned child disconnect. A disconnect + exception or timeout is not stop evidence; retain the handle for retry. + - **OpenCode:** request abort and independently poll the vendor session until idle. An abort HTTP + acknowledgement alone is not proof. + - Confirm harness stopping even when the bridge task has already finished or been cancelled by a + normal Stop action; task completion alone is not proof that vendor tools are idle. +5. Emit critical/replayable `preservation_prepared` with operation ID, generation, + `executionStopped`, and an optional safe error. The Control Plane persists matching preparation + evidence before ACK. Ignore stale generations and operation IDs. +6. Duplicate preparation replays the cached result. A user-approved retry of a completed, + unsuccessful preparation may use a new operation without reopening admission. In-flight + preparations cannot overlap. + +Stop only the active prompt and its tools. Do not stop editors, terminals, VNC, or unrelated +background services, or introduce a general supervisor drain/restart framework. The capture has +native provider filesystem consistency, not arbitrary application consistency. Unsaved buffers, +external side effects, and writes after the capture point are outside the guarantee. + +If execution stopping cannot be confirmed, this implementation fails closed rather than taking an +unlabelled crash-consistent snapshot. No degraded-capture subsystem is required. + +## 7. Checkpoint capture, retirement, and normal continuation + +Select the persistence mechanism from provider capabilities: + +- Providers supporting filesystem snapshots (Modal, Vercel) create an independent recovery artifact. +- Persistent-resume providers without session snapshots (E2B, Daytona) use explicit + `intent: "preserve"` stop/pause and verify retained state. A missing object cannot produce a + retained receipt. E2B pause conflicts must be reconciled as actually paused. + +Do not fabricate image IDs for retained objects. Do not select a mechanism solely from whether a +provider has a hard deadline. + +After verifying the recovery point, commit the receipt before a separately destructive retirement. +For independent artifacts, use typed `intent: "destroy"` to retire the captured source only; for +retained objects, keep the object. Reasons remain diagnostics, not the new intent contract. Vercel +snapshots and E2B pauses end execution as part of capture; their confirmed stop result avoids an +unnecessary second stop. + +Generation and operation checks guard late results and publication. Keep the previous receipt until +a new one is verified. Ordinary non-destructive snapshots share the capture gate and cannot +overwrite a newer generation's recovery state. If one is already in flight, fence new work and await +its bounded completion before sending final preparation and taking the final capture. It does not +count as the final snapshot merely because it completed recently. + +Vercel's ordinary execution-complete snapshot is destructive, so route it through the same +preparation/retirement flow. Other ordinary snapshots retain their existing trigger, with the shared +gate. + +Only after receipt commit **and** confirmed source retirement: + +- Mark the Sandbox stopped, revoke live access URLs, and detach its socket. +- Persist/publish `saved`. +- If the shutdown interrupted an active prompt, hold later prompts until the user explicitly resumes + queued work. Never replay the interrupted prompt automatically. +- If shutdown reaches a clean prompt boundary, continue queued/new work automatically. +- Prefer the explicit final receipt over an older snapshot or persistent-source heuristic. +- Require compatible runtime version and matching provider ownership. +- Never silently fresh-spawn if this restore/resume fails. + +A new generation must again publish lifetime metadata and complete the runtime handshake. This +prevents E2B's retained preparation fence from rejecting the next prompt. + +## 8. Restarts, failures, and recovery + +The SQLite record, not a background promise, owns the operation. + +| Observation | Behavior | +| --------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Restart while draining | Resend the same correlated preparation within its original deadline. | +| Restart after preparation | Continue capture using the durable stop evidence and deadline. | +| Restart during capture / response lost | Persist `unknown`; do not blindly repeat capture or retire the source. | +| Restart while retiring | Reconcile/retry retirement of the exact source with the committed receipt. | +| Stop deadline missed | Persist `failed`; hold dispatch and retain the source/previous receipt. | +| Provider returns pending artifact | Poll within the capture deadline; do not announce success. | +| Provider rejects, times out, or returns ambiguous failure | Persist `unknown`; adapters may wrap transport ambiguity in a failure result. | +| Capture succeeds but retirement is unconfirmed | Keep the new receipt, publish `unknown`, and forbid overlapping replacement. | +| Saved artifact/object missing or incompatible on restore | Hold recovery; never replace it silently with a clean checkout. | +| Old generation result arrives late | Ignore publication and status changes for the current generation. | + +Provider-native readiness/readback is used where available. A universal capture-result journal, +automatic artifact cleanup service, and arbitrary snapshot history browser are not prerequisites. An +unrecoverable response remains explicitly unknown. + +Receipt availability is separate from proof that the **current** source is retired. Persist that +proof on verified retirement and carry it through restore preflight, then invalidate it before +calling the provider to restore or resume. A preflight failure keeps dispatch held but allows an +explicit saved-state retry without trying to retire a nonexistent target. A lost provider response +does not prove no new sandbox was started: retained-object resume keeps its known handle so recovery +can stop it again before retrying; snapshot restore with no returned handle remains unknown. Late +failures must match the attempted generation before changing recovery state. + +Successful provider creation/resume must return an explicit lifetime receipt. If a trailing metadata +read fails after ownership was established, return the successful handle with an `unknown` lifetime, +not a failed startup that loses ownership. Unknown lifetime deliberately holds dispatch; running +without confirmed checkpoint-and-shutdown support is not a supported degraded mode for new launches. + +The UI's recovery commands reuse authenticated Session WebSocket handling and existing lifecycle +permission: + +- **Retry shutdown:** only from `failed`, where capture did not start. Keep admission closed and + retry preparation with remaining time. Unknown capture cannot be retried blindly. +- **Restore saved state:** only when a verified receipt exists, after an explicit warning that + changes since the last save may be lost. Confirm old execution ended, then let queued/new work use + that receipt. Only authoritative provider expiry can independently prove the current source + execution ended. A conservative request-start bound, or an older record without provenance, still + requires verified retirement; proof for an earlier source does not cover a later restore. +- **Resume queued work:** only from `saved` when an active prompt was interrupted and a verified + recovery point exists. Resume from that checkpoint without replaying the interrupted prompt. +- If no usable receipt/known source exists, retain the visible hold. Starting a separate session + remains an existing user action, not an automatic data-loss fallback. + +Generic stop-confirmation timeouts, execution-complete queue pumps, and lifecycle cleanup must not +override a shutdown-owned hold. Explicit session cancellation still cancels queued execution; it is +not a new promise of backup during unexpected teardown. + +## 9. Web UI and protocol projection + +Add optional `sandboxPreservation` to the canonical Session snapshot and a `sandbox_preservation` +semantic server message using the same shared schema. + +These deployed wire fields, runtime protocol identifiers, and the `sandbox_preservation` SQLite +table retain their compatibility names. The shared `sandbox-shutdown` module exports +`SandboxShutdownState` and `sandboxShutdownSchema`; renaming these source symbols does not change +the serialized shape. Internal control-plane modules, runtime preparation, UI callbacks, and +user-facing wording use shutdown terminology instead. Runtime manifest keys, version strings, +generation values, and existing log-event identifiers remain unchanged by the naming cleanup. + +The public view includes phase, reason, effective expiry/drain times, last successful save time, +safe error text, receipt existence, and server-authoritative `availableRecoveryActions`. Snapshot +and semantic updates use the same eligibility checks as command execution. A receipt belonging to +another provider is not an available restore action. The view excludes provider artifact IDs, +tokens, and private access credentials. Missing action metadata disables recovery controls. + +The existing `recover_preservation` command accepts an optional `clientRequestId`. New clients await +a sender-only `shutdown_recovery_accepted` response or a correlated error and disable recovery +controls while pending. Acceptance confirms the request, not completed restoration. Disconnects and +timeouts surface an unconfirmed result and do not retry automatically; durable status remains the +source of truth. Legacy commands without an ID do not receive the new acknowledgement. + +The reducer replaces this state from the initial/reconnect snapshot and applies semantic updates. +Show compact stopping/saving/retiring/saved banners and persistent failed/unknown warnings. When a +successful checkpoint interrupted an active prompt, explain that its partial work was saved, the +interrupted prompt will not replay automatically, and later prompts remain held behind an explicit +**Resume queued work** action. Clean successful shutdowns add no mandatory user step. + +## 10. Implementation map + +| Area | Files / responsibility | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Policy settings | Shared integrations types; control-plane settings normalization, resolved settings, spawn context/children; web Sandbox settings draft/editor. | +| Provider boundary | `sandbox/provider.ts`, five adapters and REST clients; actual lifetime receipts, deadline propagation, explicit stop intent, ready/retained verification. | +| Modal | Authenticated explicit stop endpoint; existing filesystem snapshot API bounded by the absolute deadline. No experimental snapshot APIs. | +| Coordinator | `session/sandbox-shutdown.ts` and `sandbox-shutdown-repository.ts`; additive SQLite migration 54. | +| Lifecycle | `sandbox/lifecycle/manager.ts` and `shutdown-policy.ts`; admission, readiness, generation/lifetime publication, checkpoint ownership, recovery, watchdog fencing. | +| Session integration | Queue admission, earliest alarm handler, critical event processing, snapshot reader, existing authorized client-command route. | +| Runtime | Bridge generation/preparation protocol, critical event forwarding, Claude/OpenCode stop confirmation. | +| Web | Session socket reducer/hook and `SandboxShutdownBanner` with failure recovery and explicit resume after interruption. | + +The public repository worktree is the only implementation target. The production clone, deployments, +and GitHub issue state are unchanged; changes are delivered through a public-repository pull +request. + +## 11. Verification and release boundary + +Automated coverage must establish: + +1. Settings units/defaults/minimum, scope inheritance, child lifetime/buffer inheritance, and + consistent validation of explicit and omitted buffers. +2. Finite, no-deadline, and unknown lifetime handling; provider caps and resumed E2B `endAt`. +3. Absolute admission at drain, matching generation handshake, stale-event rejection, and + deadline-bounded preparation. +4. Exactly-once interruption, held pending work, natural-completion ordering, ordinary/final + snapshot exclusion, and suppression of generic destructive cleanup. +5. Ready artifact/retained-state proof; NotFound is not proof of saved state; source retirement only + after receipt publication (or atomically with checkpoint capture). +6. Duplicate commands, control-plane restart in each phase, capture timeout, late results, explicit + failure recovery, and no silent fresh fallback. +7. Durable reconnect UI state and lifecycle authorization for recovery. +8. Cloudflare/Workerd integration plus shared Node-compatible persistence/alarm abstractions. + +Local automated tests and typechecks are necessary, not proof of deployed provider behavior. Before +production rollout, run a provider-backed canary for **each** of Modal, Vercel, E2B, and Daytona: + +- Write tracked, untracked, ignored, and secondary-repository sentinels plus a manual saved edit. +- Start an active tool; trigger the finite drain or existing inactivity stop. +- Verify the interrupted prompt does not continue/replay and queued work does not overlap capture. +- Restore/resume and verify sentinels and sandbox-local harness state. +- Exercise both harness stop implementations and at least one lost-response failure. +- Measure stop/capture/retirement latency against the 10-minute default and 5-minute minimum. Native + retention policies remain provider-specific; this is not an indefinite-backup SLA. + +Ship runtime protocol support and rebuild cached repository images before enabling the new Control +Plane for new launches. Runtime generation 71 raises the rebuild floor without raising the existing +snapshot compatibility floor or silently discarding older saved state. Existing active generations +without shutdown metadata remain legacy/unprotected until a new launch; do not fabricate an expiry +from deployment time. Eligible older snapshots and retained sandboxes remain restorable under legacy +lifecycle policy; they do not acquire confirmed-shutdown guarantees. A new launch selected for +confirmed shutdown that fails its protocol handshake remains visibly held. + +Structured `sandbox.preservation` logs carry phase, provider, generation, operation ID and expiry. +Persisted warnings make uncertain outcomes visible after reload. Provider-backed canaries and +production deployment were not performed as part of local implementation. + +## 12. Review decisions and scope + +The independent design review identified four avoidable expansions. This implementation incorporates +the narrow alternatives: + +- Explicit continuation after an active prompt is interrupted; clean successful shutdowns still need + no acknowledgement. +- Active prompt/tool containment, not whole-workspace service shutdown. +- Honest persistent unknown outcomes, not mandatory provider-side result journals. +- Existing generation identity, not a second lease epoch or clock protocol. + +The provider/runtime review additionally required retained-state readback, a fresh authenticated +generation on memory resume, ready-before-publication, and no false success when an SDK disconnect +or provider lookup fails. + +Deferred deliberately: degraded captures without stop evidence, universal remote operation +reconciliation, automatic lease renewal, cross-provider recovery, snapshot history/retention +management, and unrelated service supervision changes. diff --git a/docs/plans/managed-skills.md b/docs/plans/managed-skills.md index 2be04e646f..e252ab8c0d 100644 --- a/docs/plans/managed-skills.md +++ b/docs/plans/managed-skills.md @@ -5,6 +5,14 @@ Proposed design for V1. This document intentionally distinguishes product-visible version control, which is deferred, from immutable internal revisions, which are required for reproducible sessions. +**Historical baseline note (2026-10-02):** The authorization and environment-naming descriptions +below record the baseline when this proposal was written, not current product behavior. The product +now has workspace roles, Teams and membership checks, team-owned sessions/environments/automations, +team secrets, and repository grants. Environment names are unique within an owning team or the +workspace. Repository skills remain workspace resources with permission and grant checks, not +team-owned resources. See [Authentication and Authorization](../AUTH.md) for current access rules; +the baseline and V1 decisions below are retained as historical design context. + ## Summary Open-Inspect should let admitted users create and edit reusable agent skills in the web application, @@ -232,9 +240,7 @@ create-session request carries a discriminated choice, never an ambiguous nullab ```ts type SessionSkillSelection = - | { mode: "all" } - | { mode: "none" } - | { mode: "profile"; profileId: string }; + { mode: "all" } | { mode: "none" } | { mode: "profile"; profileId: string }; ``` Bot, automation, Slack, Linear, and GitHub-created sessions use `{ mode: "all" }` unless their diff --git a/docs/plans/sandbox-image-dependency-consolidation.md b/docs/plans/sandbox-image-dependency-consolidation.md index 7451dccb16..a0448e2729 100644 --- a/docs/plans/sandbox-image-dependency-consolidation.md +++ b/docs/plans/sandbox-image-dependency-consolidation.md @@ -7,8 +7,8 @@ earlier proposal for recipe/inventory identities, a release registry, promotion/ and release-aware prepared-image refresh. The problem is duplicated tool versions, dependency resolution, installation scripts, and service -checks across Modal, Daytona, E2B, Vercel, and OpenComputer. Updating one dependency should involve -one manifest/lock change and a consistent build workflow. +checks across Modal, Daytona, E2B, and Vercel. Updating one dependency should involve one +manifest/lock change and a consistent build workflow. ## Architecture @@ -72,10 +72,10 @@ outputs under `.cache/sandbox-images`; remove them only when no native build is The common installer does not hide native SDK differences. Adapters retain their actual transport and lifecycle behavior, including Modal eager image construction, E2B template build/spawn, Daytona -snapshot creation, Vercel upload/snapshot, and OpenComputer image construction. +snapshot creation, and Vercel upload/snapshot. -Provider-specific CA/proxy configuration remains in the OpenComputer adapter. Vercel uses its Amazon -Linux/node24 substrate. Runtime user and path differences belong to the image being built. +Vercel uses its Amazon Linux/node24 substrate. Runtime user and path differences belong to the image +being built. Names supplied by Terraform are deterministic. A retry may restore and verify an existing named artifact using the artifact’s baked service checks; it must not overwrite an existing artifact. @@ -118,7 +118,7 @@ unchanged. Terraform continues to own deployment: - Modal eagerly builds/verifies the sandbox image, then deploys functions using its native image ID. -- Vercel and OpenComputer retain managed snapshot names and existing manual-reference overrides. +- Vercel retains managed snapshot names and existing manual-reference overrides. - Daytona and E2B derive new names from the source hash (E2B also includes CPU/memory settings). Their existing Worker bindings switch only after the new artifact is verified. diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md new file mode 100644 index 0000000000..f2de4ea35a --- /dev/null +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -0,0 +1,379 @@ +# Sandbox Lifecycle Manager Refactor + +This is the ownership guide for the incremental +[COL-240](https://linear.app/colemurray/issue/COL-240/refactor-sandboxlifecyclemanager-into-focused-collaborators) +refactor. [ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md) governs lifecycle and durable +shutdown authority. The [characterization matrix](sandbox-lifecycle-refactor-baseline.md) records +compatibility evidence and separate behavior gaps, not fixes claimed by extraction. + +T7's combined audit, real-storage wiring evidence and validation are supplied by +[PR #2203](https://github.com/ColeMurray/background-agents/pull/2203). Command results and release +limits are in the [closure report](sandbox-lifecycle-refactor-verification.md). A green local suite +is not deployment authorization or exhaustive interleaving safety. + +## Ownership + +Paths are relative to `packages/control-plane/src/`. + +- `sandbox/lifecycle/manager.ts` remains the public readiness, work-admission and recovery boundary. + It selects startup mode, reserves generation/token identity, sequences input awaits, registers + pending handles through VM reconciliation, records recovery invocation, dispatches providers, + claims results, rotates retry identity and owns failure/breaker accounting. +- `sandbox/lifecycle/launch-context.ts` owns environment reads, model/harness defaults, ordered + repository fields, MCP/Slack lookup, persisted-setting normalization, timeout conversion, image + scope selection, lookup/logging and explicitly requested best-effort invalidation. +- `sandbox/lifecycle/image-selection.ts` remains the pure fingerprint, harness/runtime compatibility + and provenance evaluator. Launch context reuses it rather than reimplementing policy. +- `sandbox/lifecycle/sandbox-access.ts` owns artifact-write mechanics, terminal JWT signing/reuse, + retirement and access notifications. It has no lifecycle state or eligibility authority. +- `sandbox/lifecycle/vm-startup-reconciliation.ts` owns pending-reference registration, + lost-response lookup recovery and bridge lookup, including all five interleaving-sensitive + bridge/auth fields. It reconciles an authorized attempt without owning startup admission or + shutdown/recovery policy. +- `sandbox/lifecycle/allocation-cleanup.ts` owns stateless bounded late-result destruction, + rejected-cleanup retry rearming and matching-handle completion. Rejection/claim authority, + admission flags and prior-generation replacement retirement remain in the manager. +- `sandbox/lifecycle/provider-stop.ts` owns the shared local stop bound, abort/timer mechanics and + explicit `confirmed`/`not_stopped` outcome contract, not either caller's retirement policy. +- `sandbox/lifecycle/watchdog-effects.ts` owns stateless connect-timeout, stale-heartbeat, stale + snapshot/stop and inactivity effects. The manager retains the complete boot-budget effect, + captures alarm facts, evaluates/dispatches policy, schedules healthy/warning checks and owns the + termination guard. +- `session/sandbox-repository.ts` owns conditional SQL and encrypted access storage. + `session/sandbox-shutdown.ts` and `sandbox-shutdown-repository.ts` own the durable + shutdown/checkpoint protocol, receipts, holds, source retirement and recovery. +- `session/components.ts` and `sandbox-lifecycle-adapters.ts` compose the existing graph. Consumer + ports remain in `sandbox/lifecycle/ports.ts`; consumers do not gain launch, access or shutdown + internals. Session access readers retain authentication/read eligibility and decryption rechecks. + +Each increment must integrate before its successor; the manager is not intended to become a tiny +facade or lose lifecycle arbitration. Fatal/unresponsive termination, generic startup claims, +failure accounting and public checkpoint/admission/recovery policies remain manager +responsibilities. + +## Launch Contract + +`SandboxLaunchContext` is explicitly constructed as a class. Its constructor receives only a +two-method environment/repository reader, default model and MCP/Slack ports, provider metadata, +optional image lookup and lazy logger. These dependencies are held in readonly fields; the class +owns no mutable lifecycle state, storage/shutdown authority, provider operations, full-manager +reference or service locator. Construction does not read the session or resolve log context. + +`AgentLaunchFields`, `RepositoryLaunchInputs` and `ResolvedSandboxSettings` name the return shapes. +Agent fields are explicitly mapped into provider configs. Repository payload fields are restricted +to the existing scalar identity/base branch and optional ordered member list; single-repo sessions +omit the list unless a base SHA requires it. Nested owners and immutable base SHAs remain intact. +`resolveSandboxSettings(session)` returns normalized/provider-filtered settings and the derived +timeout together, so callers cannot assemble that ordering incorrectly. + +`resolveImageBuildScope(session, repositories)` is a plain synchronous function returning +`ImageBuildScope | null`. The manager awaits the promise-only lookup only for a non-null scope. +Environment scope takes precedence and never falls back to a repo image. Ad-hoc multi-repo and +repo-less sessions have no scope. An eligible scope keeps its existing await even when lookup is +disabled or the repository list is empty. + +## Sequencing Invariants + +- Reserve identity and shutdown ownership synchronously, invalidate old credentials, then publish + the generation-conditional hash before asynchronous input work. No launch read moves ahead of it. +- Fresh: retire prior provider, env, agent/repositories, eligible image lookup, MCP, Slack, + settings/timeout, pending registration, provider create. +- Restore: seed snapshot runtime authority, retire prior provider, env, agent/repositories, Slack, + MCP, settings/timeout, pending registration, recovery-invoked recording, provider restore. +- Resume resolves only settings/timeout and its existing access contract. It gains no environment, + repository, MCP, Slack or image work. Bridge settings remain after pending-reference eligibility. +- Lookup misses/errors never invalidate images. Only the manager's confirmed-unavailable branch + requests best-effort invalidation and reserves a fresh identity/token for base-image retry. + Transient provider errors retain valid images; saved-state failure never silently becomes fresh. +- Preserve provider claims, generation/hold checks, access-write atomicity, conservative lifetime + provenance, undefined settings, milliseconds-to-seconds conversion and lazy session logging. + +There is no universal startup pipeline. Independently landed context injection, construction safety +or provider recovery behavior must be preserved, not implemented or removed as incidental cleanup. +No schema, wire/runtime/provider-backend contract, timeout or retry policy changes are authorized. + +## Access Contract + +`SandboxAccess` receives only an artifact storage port, broadcast, socket observation/detachment, +the provider's resumable-stop capability check, dashboard URL builder and lazy logger. It owns no +mutable lifecycle flags, generation checks, signing-key retention, encryption key or full-manager +reference. Its constructor performs no dependency work. + +Composition constructs repository/socket/messenger leaves, then access, then shutdown, then manager. +Shutdown receives a callback to `access.retireShutdownAccess()` directly; retirement clears access, +notifies clients, then detaches with the unchanged close code and reason. There is no manager +retirement forwarding method. URL-only retirement preserves credentials on resumable providers when +supported, falls back to full clearing otherwise, and always clears tunnels and notifies. Other +termination paths keep their own existing clear/detach order. + +The manager calls individual `storeCodeServer`, `storeVnc`, `storeAndBroadcastTunnelUrls`, +`storeTtyd` operations for fresh/restore at their original await points. It still orchestrates +secret reads and atomic `completeProviderResume` writes for resume; VM reconciliation owns bridge +completion with an expected pending reference. These operations are intentionally not unified. +`reusableTtydToken` validates an already-read JWT synchronously so disabled terminal access does not +acquire a new await. `mintTtydToken` uses the transient launch key, existing session/sandbox claims +and the single terminal TTL. A hash-only restart or expired/missing JWT cannot renew access. + +`broadcastSandboxDashboardUrl` and `broadcastProviderAccessIfConnected` preserve separate, +repeatable notifications, alongside tunnel notifications. The manager retains publication fallbacks +and caller-specific catches, including committed recovery's access failure treatment. Atomic +repository completion rechecks generation, status and fence after encryption; only bridge supplies +an expected pending reference. Fresh/restore per-artifact writes remain unguarded, as characterized +separately in the baseline gap notes, not silently hardened here. + +## VM Reconciliation Contract + +`VmStartupReconciliation` owns `bridgeResolution`, `bridgeRetryGeneration`, `bridgeStartupClaim`, +`bridgeResolvedStartup` and `vmStartupAuth`, with no duplicate manager state. The manager calls +`registerForegroundAuth`, `beginForegroundRetry` and `finalizeForeground` at the original points; +none exposes mutable state or a generic field setter. Registration retains the actual foreground +generation object. Foreground completion compares generation/claim object identity; bridge auth, +claim and queue checks retain their value comparisons. Retry drops old auth before reservation/hash +publication yields, then registers the replacement key after successful reservation. An older +finalizer cannot clear newer auth. Inconclusive foreground lookup hands off its claim and signing +key to a later equal-valued bridge; only the completing generation clears that key. + +Dependencies are the existing provider create/pending/classification/lookup hooks, three repository +operations, pending/resolved shutdown-handle registration, session reading, launch-context settings, +terminal signing/access publication, lazy logger and optional background submission. The only +manager callback is `acceptResolvedStartup(generation, providerObjectId, lifetime)`, which invokes +the existing generic `claimProviderStartup`. That operation still clears admission pending, refuses +held-current adoption without destruction, conditionally commits, cleans unacceptable late results, +records lifetime and publishes announcements. Resume still uses that same manager operation. + +The reconciler's bridge entry is synchronous and starts its background factory synchronously. An +in-flight lookup deduplicates equal generations and queues the latest newer generation; eligibility +is checked again before settings and provider work. Atomic bridge access completion still supplies +the expected pending reference, leaving post-encryption generation/status/fence/reference checks to +the repository. The cache projects only identity and lifetime, never credentials. Restart can +resolve permissible provider/access facts but cannot reconstruct a terminal signing key. + +`createWithVmRecovery` returns `CreateSandboxResult | null`; `resolveUnknownVmStartup` returns +`ResolveSandboxResult | null`. Null abandons the foreground path, not evidence of absence or +permission to replay create. Lookup remains lookup-only with the original retry interval and +materialization bounds. `modalVmAllocationDetail` lives beside the Modal provider and unwraps the +same typed outcomes: `not_visible`, `other_generation` and unknown transport results remain +distinct. The boolean unknown-startup hook does not replace detail classification. Hook existence +alone does not enable VM behavior; standard Modal returns no pending allocation and does not +classify launch errors as VM-unknown. Conservative lifetime provenance and resolved-handle +replacement are unchanged. + +Restore remains manager-orchestrated: await pending registration, synchronously record +`markRecoveryInvoked`, immediately invoke restore. No new await hides that durable boundary. +`startup-errors.ts` holds the two existing internal abandonment/expiry errors shared by reservation +and reconciliation, preserving the manager's distinct catches. No provider/backend/wire/persisted +contract or public consumer port changes. + +Review follow-up explicitly corrects one inherited publication bug: a refused deferred bridge claim +no longer emits an access-change notification. Auth finalization and queued lookup draining still +run on refusal. This is a narrow behavioral fix, not a redesign of the extraction's contracts. +Access may still have committed before the manager observes a hold; this fix does not make access +and lifecycle acceptance atomic. Pending sandbox/shutdown registration also retains separate writes +without a cross-record transaction (the shutdown write precedes its alarm await). Failure atomicity +and unified acceptance require separately scoped safety work; neither is claimed fixed here. + +## Allocation Cleanup Contract + +`destroyLateProviderResult`, `rearmRejectedStartupCleanupAlarm` and `attemptRejectedStartupCleanup` +are stateless functions. Their narrow dependencies are a two-method repository port (`getSandbox`, +`updateSandboxModalObjectId`), the shared scheduler's `schedule`, explicit-stop eligibility, a stop +operation requiring a supplied handle and signal with a `confirmed`/`not_stopped` result, and a lazy +warning logger. Rearming and late destruction each accept only their dependency subset. The manager +constructs the readonly dependency wiring without invoking it; no manager reference, local ownership +flag, shutdown policy, new persisted record or raw platform alarm belongs to cleanup. + +Rejection ordering remains manager-owned: recognize `SandboxLaunchRejectedError`, synchronously +`rejectProviderStartup` to fence credentials/socket authority and retain the handle, detach with the +existing code/reason, then clear/notify access. Only the repository result `failed` authorizes +failed-status/error publication and breaker accounting. `retained` preserves terminal status/error; +`superseded` does not touch the current row or access and only attempts bounded late destruction. +Generic `claimProviderStartup` still clears the pending admission flag and refuses a current held +generation without destroying its potentially unique recovery copy. + +Rejected cleanup awaits retry scheduling before provider I/O. It stops the explicit target with +`startup_superseded`, `destroy`, the public session name/internal ID fallback and a bounded signal, +retaining the existing **undefined** `generationCreatedAtMs`. The provider adapter still interprets +absence; row age does not change pending-reference retirement classification on this path. The +original stop bound and abort/timer implementation live in `provider-stop.ts`; late destruction and +the manager's separate replacement-retirement operation both call `boundedProviderStop`. Timeout +error messages, logging, retry and handle-clearing policies remain with the callers. + +Review follow-up corrects the inherited false-confirmation bug: the manager's stop adapter returns +`not_stopped` when no provider method, session context or target permits dispatch, and `confirmed` +only after the provider reports success. Rejected cleanup retains its handle/retry on `not_stopped`; +confirmation-required replacement on explicit-stop providers refuses an undispatched stop before +reserving a replacement. Best-effort replacement and providers without explicit stop retain their +existing handle-clearing/continuation policy. This is a narrow behavioral fix, not a new provider +absence rule or generalized teardown operation. + +Failed, unsupported or locally timed-out cleanup retains the handle and scheduled retry. Abort +bounds local waiting only; eventual completion of a timed-out stop does not clear the handle. A +confirmed result clears only when sandbox ID, reservation timestamp and handle still match, without +retargeting to a newly read row. It does not clear the fence or `startup_rejected` marker. Manager +shutdown-alarm dispatch still prioritizes rejected cleanup over ordinary shutdown processing, +including holds, and production runtime reconstruction retains its existing rearm hook. + +Generic superseded-result destruction remains bounded best effort rather than a new durable cleanup +record. The assembled alarm handler can retry a failed rejected cleanup twice per delivery because +it invokes shutdown processing before and after terminal-projection I/O, before generic watchdogs. +These inherited semantics, along with prior-generation unscoped clearing and access-retirement +failure boundaries recorded in the baseline, are unchanged; no stronger retirement/exactly-once +guarantee is claimed. + +## Watchdog Contract + +`failConnectTimeout`, `terminateStaleHeartbeat`, `snapshotAndStopStaleSandbox` and +`stopForInactivity` are stateless functions. Their dependencies are narrowed storage, broadcast, +socket, shutdown and access ports plus named failure accounting/reporting, checkpoint triggering, +provider capability checks and generation-targeted best-effort stop operations. Each function takes +only its dependency subset; there is no manager reference, flag setter, scheduler or lifecycle +class. Construction does not call dependencies, and logger resolution remains lazy, including +detached snapshot rejection logging. Existing boot-phase parsing and failure text are reused without +new vocabulary, defaults or messages. + +Review follow-up keeps `failBootBudget` entirely in the manager, alongside its termination/admission +state. This replaces the effect-to-manager `stopBootBudgetSandbox` callback and private guard +wrapper; the complete ordering and guarded stop are visible in one method. Moving termination +ownership or splitting this operation behind a one-off reverse callback is not part of this +increment. + +`handleAlarm` retains the initial shutdown hold check, pre-await sandbox/time/client/provider-handle +capture, ID-plus-reservation-timestamp generation predicate, `evaluateAlarmPolicy` dispatch, +healthy/warning scheduling and `SandboxAlarmResult`. `session/alarm/handler.ts` still prioritizes +shutdown before and after other alarm work and uses those same results to fail/redrive the queue. +The shared `AlarmScheduler` remains the only alarm/deadline owner. + +- Connect timeout fails/counts/clears access before explicit stop, fences only when stop is + available and the retained source is not held, then publishes status/error after stop. Without + explicit stop, a late bridge may still self-heal the unfenced failed boot. +- Heartbeat retires incomplete boots without snapshotting, preserves provider-managed resumable + state, and delegates shutdown-required ready-workspace capture to shutdown in emergency mode. + Legacy ready-workspace capture precedes explicit destroy-stop, then runtime shutdown and final + detach. Without explicit stop, capture stays detached. Existing hold/generation checks after + awaited capture and generation checks after stop remain where they were; no new confirmation + episode is introduced. +- Boot budget holds a failed retained source without runtime shutdown, fencing, detach or provider + destruction. Otherwise it sends runtime shutdown, fences, fails/counts/retires access, publishes + and persists the failure, then detaches. Within the same manager method, only explicit + destroy-stop runs inside the `isTerminatingSandbox` try/finally interval. Concurrent spawn is + excluded during that stop, not during earlier publication/detachment. Other watchdog paths + intentionally do not acquire this guard; fatal termination keeps its own interval. +- Inactivity awaits shutdown ownership first and falls back only when unmanaged. Access retirement + retains capability-based URL/secret rules. Resumable stop skips snapshot/runtime shutdown; legacy + capture precedes runtime shutdown, explicit destroy-stop and final detach/warning. Existing + post-capture hold/generation and post-stop generation checks remain unchanged. + +Stops receive the observed provider handle and generation timestamp when the captured handle exists. +An inherited exception remains: a captured absent handle is passed as `undefined`, which permits the +manager's stop adapter to reread the current row. This can retarget a replacement after an awaited +unmanaged shutdown decision. T7 characterizes that trace, including unscoped successor access/status +retirement, and connect-timeout error publication after replacement. Neither is fixed by extraction. +Other retirement exceptions retain their existing boundaries. The baseline's separate gaps remain +separate behavior work, not extraction fixes or exhaustive interleaving guarantees. + +Related-work reconciliation at T6's starting `60930ce`: COL-245 is integrated via PR #2170, after +the launch/access/VM increments. COL-238 remains In Progress and PR #2141 is open/unmerged; its +proposed heartbeat confirmation is absent from this checkout and is not imported. COL-150's alarm +policy and the current COL-151 boot-phase helpers are reused unchanged. Recheck independently landed +heartbeat work when integrating subsequent increments. + +## Verification + +Keep direct narrow-dependency tests for input resolution and lookup effects. Keep assembled tests +for exact fresh/restore/resume payloads, reservation before asynchronous work, distinct integration +ordering, the no-await boundary for ineligible images, retry identity rotation and lazy bridge +settings. Image compatibility policy belongs to `image-selection.test.ts`; real-storage and Workerd +tests retain generation, shutdown and early-connect coverage. + +Access coverage includes direct retirement/fallback/notification-order tests, assembled JWT and +resume/restart/bridge tests, committed recovery failure boundaries, real encryption interleavings +and real composition retirement/construction checks. Existing session access-reader and repository +tests remain independent; collaborator mocks do not replace assembled coverage. + +VM coverage retains assembled lost-create/lost-restore, bridge-first/provider-first, late-token, +restart, visibility/transport bounds, fencing, queued-generation and lifetime tests. Direct +narrow-port tests add object-identity finalization, retry reset/new-token isolation, identity-only +cache reuse, post-await atomic-commit refusal without publication, successful older +lookup/latest-generation queueing, pending expiry/supersession and non-VM hook gating. Assembled +launch tests pin both successful restore marker order and absence of invocation after rejected +pending registration. Real repository encryption-race tests retain reference/generation rechecks; +direct commit substitutes do not replace them. ESLint prevents public consumers from importing VM +reconciliation internals. + +Allocation cleanup coverage retains assembled rejection/repository tests and adds controlled +schedule/stop gates, local timeout followed by late completion, exact stop arguments, unsuccessful +provider results, skipped dispatch/missing session context, capability/method absence, +generation-ID/timestamp/handle replacement isolation, terminal failure-accounting ownership and +superseded results against a held successor. Existing real-storage held-current claim tests remain. +Workerd now reconstructs the production runtime over a persisted rejected row and durable shutdown +hold, exercises the actual rehydration hook/shared deadline storage, and confirms +failed/successful/repeated cleanup preserves the hold and recovery receipt. ESLint keeps cleanup +internals unavailable to public consumers. + +Watchdog coverage retains all assembled manager/policy/effect suites and real-storage/Workerd alarm +recovery and shutdown checks. The boot-budget trace names failure accounting, access retirement, +status/error publication and persistence before detachment and guarded stop; actual spawn remains +blocked during stop and resumes after stop failure. Retained-source tests assert no send/detach and +no duplicate failure charge under the hold. Deferred heartbeat/inactivity stop tests independently +replace ID or timestamp for legacy and resumable providers and assert post-stop abandonment without +successor mutation, detachment or new publication. Reads return fresh row snapshots rather than +aliases of the persisted fixture row; the captured alarm row remains unchanged across writes. +Same-ID/timestamp continuations vary status, provider handle, heartbeat/activity and access URL to +prove non-identity changes do not abandon the generation. Deferred ordinary captures prove in-flight +and uncertain ownership blocks competing generic teardown and subsequent stop; inactivity ownership +tests pin the absence of fallback effects before a held/owned decision. These extend rather than +replace the existing half-boot/ready, destructive-snapshot, detached-capture, late-bridge, +checkpoint-replacement, queue-result, duplicate-alarm and shared-deadline tests. ESLint keeps +watchdog internals unavailable to public consumers. + +T7's `test/integration/sandbox-vm-reconciliation.test.ts` assembles the production runtime with real +Workerd SQL/encryption and substituted provider/socket transport. It reconstructs a persisted +foreground reservation, completes bridge lookup through attachment/readiness, and separately changes +the pending reference or reservation timestamp after real ciphertext is produced. A refused atomic +completion causes no shutdown adoption or access publication. Successful reconstruction preserves +early readiness and conservative lifetime, decrypts access through a reconstructed repository, and +cannot mint terminal access from the persisted hash. It also pins current admission semantics: +acknowledgement plus known pending lifetime can admit work before lookup finishes after +reconstruction; the original instance's foreground-pending flag still blocks it. These gates are not +equivalent. + +An explicitly ambiguous original create subsequently settles through foreground lookup. +Post-settlement assertions retain supersession refusal and distinguish the original instance's +still-live terminal signing key from the reconstructed instance's lack of one. Tests select the +single named VM-resolution task, not the composition root's whole background-task list. + +The rejected-reconstruction test now enters `SessionServer.onScheduledDeadline()` rather than +calling only manager shutdown processing. It covers the production pre/post-projection passes, +durable retry-before-stop, delivery acknowledgement and duplicate wake-ups under a retained shutdown +hold. It asserts each stop target/intent and the real host alarm, consumes that alarm before +simulated due delivery, and verifies failure rearming and the final retry's no-op drain after +successful cleanup. Existing assembled-manager, real-SQL, Workerd, scheduler and Node conformance +suites remain intact. ESLint additionally protects launch-context and startup-error internals using +the existing import rules, including extension-bearing imports; composition alone supplies launch +integration ports. + +`manager-shutdown.test.ts` isolates the assembled shutdown/recovery cases, including the committed +access/publication failure matrix, from the manager's orchestration suite. It retains real +manager/access/shutdown composition with test storage/provider ports; real SQLite and Workerd checks +remain separate. The baseline gap notes distinguish inherited unsafe outcomes from desired safety +guarantees; green characterization tests do not make those outcomes safe or authorize hardening in +this extraction. + +Build shared first, then run sequentially from the repository root: + +```bash +npm run build -w @open-inspect/shared +npm test -w @open-inspect/control-plane -- --maxWorkers=1 +npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1 +npm run typecheck -w @open-inspect/control-plane +npm run build -w @open-inspect/control-plane +npm run lint -w @open-inspect/control-plane +npm run test:lint-sandbox-boundaries +npm run format:check +git diff --check +``` + +The worker flag bounds sandbox resource use without selecting a subset of tests. Record checkout +details, exact command results and blockers in the closure report and PR/issue handoff rather than +this enduring ownership guide. Provider substitutes are not live-provider verification, and this +refactor does not authorize deployment or claim exhaustive interleaving safety. diff --git a/docs/plans/sandbox-lifecycle-ownership-implementation.md b/docs/plans/sandbox-lifecycle-ownership-implementation.md new file mode 100644 index 0000000000..31f684d7d1 --- /dev/null +++ b/docs/plans/sandbox-lifecycle-ownership-implementation.md @@ -0,0 +1,181 @@ +# Sandbox lifecycle ownership: C1 implementation + +## Status and scope + +Implemented the behavior-preserving C1 increment from Design C on branch +`sandbox-lifecycle-ownership`, based on `222f1c002f1ba9ff7624090e1424b7e556b772f6`. Implementation +and verification used an isolated worktree, preserving the existing public checkout, design +artifacts, and production checkout. This report records pre-publication validation; no deployment +was performed. + +**C1 is implemented; C2 is not.** This follows the design's phase-3 gate: stop before +preservation-dependent changes when that protocol is absent. No snapshot status was removed and no +preservation guarantees were introduced. + +## Ownership changes + +| Responsibility | Owner after C1 | Callers retain | +| ------------------------------- | ------------------------------------------- | ---------------------------------------------------------- | +| Ordinary command eligibility | Pure lifecycle decisions | Queue claims, push correlation, transport delivery | +| Interactive access eligibility | Pure lifecycle decisions | Credential decryption and post-await field revalidation | +| Authoritative socket lookup | WebSocket manager | Socket identity, recovery, and terminal socket cleanup | +| Accept runtime readiness | Existing lifecycle manager + repository CAS | Event persistence, queue wake, inactivity scheduling | +| Sandbox cancellation transition | Existing lifecycle manager | HTTP session authorization/status and message cancellation | + +`getSandboxCommandTarget()` replaces reconstruction from a ready getter followed by an attached +getter. It returns `dispatch` with a socket, `booting`, or `unavailable`. It is an ephemeral +classification, **not a lease across awaits**. Lifecycle control commands continue to use the +attached socket path. + +The runtime event handler and HTTP lifecycle handler have narrow dependencies without raw lifecycle +writes. The access reader receives read-only repository ports. No new coordinator, generic state +machine, or manager-above-manager was introduced. Existing fatal/alarm/launch/retirement transitions +already reside in the lifecycle manager. Aggregate creation of the initial pending row remains an +initialization responsibility, not a second transition-policy owner. + +## Compatibility contract + +| Situation | Preserved behavior | +| ------------------------------------ | --------------------------------------------------------------------------- | +| Ready + authoritative socket | Ordinary commands can dispatch; access is available | +| Snapshotting + authoritative socket | Ordinary commands can dispatch; access remains unavailable | +| Attached booting bridge | Queue defers; push reports the existing starting error | +| No usable bridge | Existing acquisition path; push retains the manual-push assumption | +| Failed/stopped/stale row | Ordinary dispatch unavailable; lingering sockets are cleaned up | +| Failed reconnect | Existing separate admission policy, not conflated with dispatch | +| Session cancellation | Cancel queued/running work first, then sandbox shutdown, then stopped write | +| Stale row on cancellation | Becomes stopped; failed/stopped rows stay unchanged | +| Local shutdown send returns false | Still writes stopped, as before | +| Late ready after cancellation | Event can be recorded; readiness CAS cannot revive stopped compute | +| Credentials change during decryption | Access response rejects rather than returning stale credentials | + +Readiness keeps its exact effect order: event processing and persistence, guarded readiness commit, +activity timestamp, ready publication, queue wake, then best-effort inactivity scheduling. +Duplicate/rejected readiness does not repeat the wake or publication. Admission generation/token +snapshots and persisted socket authority checks are unchanged. + +Persisted status values, wire messages, schema, provider contracts, runtime code, timeouts, retry +constants, snapshot save/restore behavior, and recovery selection are unchanged. `warming` remains a +presentation-only union member; the policy matrix covers it without introducing a persisted state. + +Two implementation-level differences are intentional: readiness logging now uses the lifecycle +manager's logger, and queue/push make one socket lookup instead of two, avoiding redundant attempts +to close an already-dead lingering socket. + +## Verification + +Run commands from this worktree's repository root. Shared was built before dependent validation. The +pre-change focused baseline passed 529 tests. + +| Check | Result | +| --------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `npm run test -w @open-inspect/control-plane -- --silent` | 303 files, 4,796 tests passed | +| Full Workerd integration suite, before final added scenario | 109 files, 1,306 passed, 1 skipped | +| `npm run test:integration -w @open-inspect/control-plane -- --silent` | 109 files, 1,307 passed, 1 skipped | +| Added cancellation → late-ready Workerd scenario and existing early-connect suite | 7 tests passed | +| `npm run typecheck -w @open-inspect/control-plane` | Passed Worker, Node, unit-test, integration-test configurations | +| `npm run build -w @open-inspect/control-plane` | Worker and Node bundles passed | +| ESLint on all changed/new TypeScript; Prettier; `git diff --check` | Passed | +| Independent architecture reviews | No blocking command/access or readiness/cancellation regression found | + +New or strengthened checks include: + +- An explicit compatibility matrix for every status union member; separate + access/reconnect/cancellation decisions. Transport tests cover missing and terminal sockets. +- Lifecycle-owned cancellation ordering, excluded states, missing transport, unsuccessful send, and + absence of unintended provider stop/fence/detach effects. +- Readiness CAS/activity/publication ordering and rejected/missing-row cases. +- Runtime-handler tests exercise orchestration through a focused readiness port, including rejected + readiness and fallible inactivity scheduling. Manager/storage tests cover readiness guards and + effect ordering; Workerd tests cover the assembled wiring. +- Access tests use real encryption and asynchronous decryption, and replace + status/identity/provider/access fields across that await. +- Workerd exercises actual HTTP cancellation and an authenticated bridge's late ready event. The + test waits for event broadcast rather than using a sleep as proof of processing. It checks stopped + sandbox, cancelled session, and the existing failed-message cancellation reason. +- Explicit read/runtime/socket/initialization ports restrict consumers' repository capabilities. + ESLint enforces imports through ports instead of concrete repository/manager implementations; + `npm run test:lint-sandbox-boundaries` verifies the real ESLint configuration. No custom AST + scanner or single-owner-file allowlist remains. + +Full suites cover existing admission races, hibernation/socket recovery, boot budget, alarm +behavior, queue claims, snapshot histories, provider adapters, and Node conformance. Automated +passing tests are evidence, not proof that every production interleaving is regression-free. No live +provider-backed canary or production traffic validation was performed. Workerd uses test provider +responses. + +## Explicitly not fixed in C1 + +The design separates policy relocation from correctness changes. This increment does not claim a new +final-revalidation/dispatch lease, monotonic attempt identity, durable exactly-once launch, stop +acknowledgement, or shutdown-before-retirement safety guarantee. It does not repair the known +status-only attach-time gap. Those require separate changes and adversarial tests, not silent +semantics added to this refactor. + +## C2 dependency and next increment + +The baseline lacks the durable preservation record, runtime preparation handshake, confirmed stop +protocol, provider capture semantics, admission hold, and recovery receipts required by C2. A nearby +implementation exists on `final-sandbox-preservation` at inspected head `ef7a92d21`, but importing +it would add 92 changed files against this baseline, spanning runtime, providers, control plane, and +web behavior. It was not silently merged into C1. + +A split preservation stack was also inspected: runtime `6493b6e51`, control plane `7023301a1`, and +settings/web `78c2c1d69`, based on `417151ff0`. These are inspected local snapshots, not claims of +deployment or approval. Revalidate exact heads before choosing an integration base. + +Preservation itself is not yet C2: it still writes/restores `snapshotting`, returns void from +checkpoint capture, and lacks per-checkpoint operation identity. + +After selecting/integrating the preservation base: + +1. Reconcile C1 with the single existing preservation owner and rerun both suites. +2. Add explicit checkpoint outcomes and operation-scoped publication; do not add another capture + journal or parallel preservation coordinator. +3. Remove snapshot status overloading with explicit access/dispatch/idle rules, including restart, + duplicate, cancellation, and late-completion histories. +4. Implement safe legacy snapshot handling and an operation-aware rollback path. +5. Validate runtime/image compatibility and provider-backed canaries before enabling behavior + changes. An expired timer is not proof capture ended. + +## C1 rollout and rollback + +This increment requires no schema or wire migration. Deploy through the normal review/release +process, observe readiness latency, queue deferrals, push-starting errors, access 409s, cancellation +outcomes, and existing recovery alarms. Compare against the baseline rather than assuming changed +ownership improves outcomes. C1 can be rolled back to its baseline without new persisted data to +reconcile. This rollback statement does **not** apply to later C2 operation records/holds. + +## Simplification Analysis + +### Core Purpose + +Centralize existing sandbox policy and transition ownership without changing behavior. + +### Unnecessary Complexity Found + +- `createSessionRuntime`: an obsolete lifecycle socket adapter remained after cancellation moved to + the manager. Removed it; the manager's adapter remains. +- `SessionLifecycleHandler` test harness: obsolete raw status and transport mocks remained after the + dependency changed. Removed them. +- Event processor test harness: removed two obsolete readiness repository mocks. + +### Code to Remove + +- Findings removed: ten lines total. No further removal required. + +### Simplification Recommendations + +1. Keep named decisions and narrow existing ports; no new framework is needed. +2. Keep C2's operation ownership with preservation instead of adding a duplicate owner. + +### YAGNI Violations + +No remaining speculative framework identified. Do not add generic operation records or new status +enums to this behavior-preserving increment. + +### Final Assessment + +Total cleanup: ten lines removed. Added abstraction complexity: low; existing lifecycle subsystem +complexity remains. Recommended action: review C1 independently; resolve the preservation +integration base before C2. diff --git a/docs/plans/sandbox-lifecycle-refactor-baseline.md b/docs/plans/sandbox-lifecycle-refactor-baseline.md new file mode 100644 index 0000000000..b5500c700b --- /dev/null +++ b/docs/plans/sandbox-lifecycle-refactor-baseline.md @@ -0,0 +1,137 @@ +# Sandbox lifecycle refactor: T1 verification baseline + +This is the verification companion to +[COL-240](https://linear.app/colemurray/issue/COL-240/refactor-sandboxlifecyclemanager-into-focused-collaborators) +and [ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md), not a replacement design. The +proposed `sandbox-lifecycle-manager-refactor.md` was not present in this checkout. No production +extraction or protocol change is part of T1. + +## Checkout and owners + +- Starting HEAD: `4e5c1f3bce26974890a822c894651688ae6e4045` (clean `main`, tracking `origin/main`); + research baseline: `eef911f36e704fc49104546a9cd52b584d8b9223`. Five subsequent commits change + session visibility/indexing, Modal VM Docker-preparation failure handling, VM lookup for partially + published tunnels, and client/provider response handling. `vm-resolve.test.ts` gains a + Docker-unavailable regression. None extracts lifecycle responsibilities. Do not replace these + changes with the research snapshot. +- COL-238 (heartbeat confirmation) and COL-161 (spawn-time context injection) were **In Progress** + in Linear at inspection, not landed in this HEAD. Current watchdog/bridge and launch-context + behavior is the tested baseline, not the proposals in those issues. Recheck at each subsequent + task; preserve any changes that land. +- `sandbox/lifecycle/manager.ts` still owns reservation, launch choice, provider claims, recovery, + failure/breaker accounting, public admission/readiness, access preparation, VM resolution state, + rejected cleanup, alarm dispatch/effects, and termination flags. `decisions.ts` and + `alarm-policy.ts` own pure policy; `ports.ts` exposes consumer boundaries; `test-helpers.ts` + assembles manager fixtures. +- `session/sandbox-repository.ts` owns conditional SQL and encrypted access; + `session/sandbox-shutdown.ts` plus `sandbox-shutdown-repository.ts` own durable holds, receipts, + source retirement and recovery. `session/components.ts` wires both. `connection-authenticator.ts` + authenticates/attaches sockets; `sandbox-events/runtime.handler.ts` reports runtime facts; + `alarm/handler.ts` orders shutdown before lifecycle watchdogs and `alarm/scheduler.ts` persists + shared deadlines. Queue/push use the public lifecycle policy, not direct shutdown decisions. + +The manager's admission/termination flags are `isSpawningSandbox`, `isTerminatingSandbox`, and +`providerStartupPending`; VM reconciliation owns `bridgeResolution`, `bridgeRetryGeneration`, +`bridgeStartupClaim`, `bridgeResolvedStartup`, and `vmStartupAuth` today, all in that same class. +`spawnSandbox` selects startup mode, `reserveSpawnIdentity` commits the launch identity, +`claimProviderStartup` admits the provider result, `resolveUnknownVmStartup` and +`resolvePendingBridge` reconcile VM responses, `attemptRejectedStartupCleanup` retries rejected +handles, and `handleAlarm` dispatches watchdog effects. `retireShutdownAccess` is still manager +wiring from the shutdown coordinator. These are **current** owners, not extracted contracts. + +## Compatibility evidence + +Paths in this table are relative to `packages/control-plane/`: `L/` means `src/sandbox/lifecycle/`, +`S/` means `src/session/`, `I/` means `test/integration/`. **P**: passed in the pre-edit focused +commands below; **N**: named existing test not selected by those commands (not a pre-edit pass +claim; related session suites were run after edits as recorded below). T1 additions were run +separately. Gaps are remaining evidence limits, not permission to change behavior during extraction. + +| # | Parent invariant | Named test evidence (pre-edit result) | Remaining gap / limit | +| --- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| 1 | One manager public policy boundary; coordinator durable owner | `L/manager.test.ts` "does not invoke collaborators during construction and honors the injected hold first" (P); `S/sandbox-shutdown.test.ts` "distinguishes unmanaged and held shutdown requests" (N) | Ownership also requires a code-boundary review; tests cannot prove no second authority was introduced. | +| 2 | ID + timestamp generation and post-await conditional writes | `S/sandbox-repository.test.ts` "refuses a ready that belongs to a generation the row no longer holds" (P); `L/manager.test.ts` "does not let a late completion touch a newer reservation that re-entered spawning" (P) | Fresh/restore per-artifact writes are not generation-conditional (see separate gap below). | +| 3 | Atomic reservation/shutdown, immediate credential revocation, conditional hash | `I/sandbox-shutdown.test.ts` "rolls back the sandbox reservation when the matching shutdown write fails" (P); `L/manager.test.ts` "fresh spawn: reserves the new identity before hashing opens the input gate", "snapshot restore: reserves the new identity before hashing opens the input gate", "abandons the attempt without failure writes when the reservation is superseded" (P); `S/sandbox-repository.test.ts` "publishes the hash scoped to the reserved identity" (P) | Old authentication racing a _second_ reservation during the hash wait lacks one assembled real-storage test. | +| 4 | Provider response, bridge, ready and acknowledgement distinct | `L/manager.test.ts` "leaves a sandbox that connected during the provider call ready when the call then fails" (P); `I/sandbox-shutdown.test.ts` "accepts early ready for a saved restore but gates the queue until provider lifetime settles", "holds queued work until a versioned runtime acknowledges its sandbox generation" (P) | Not every bridge/foreground finalization permutation is covered. | +| 5 | Queue startup versus live-push readiness | `I/sandbox-early-connect.test.ts` "holds a queued prompt while the bridge is attached but the sandbox is still booting, then dispatches on ready" (P); `S/message-queue.test.ts` "defers, without spawning, while the bridge is attached but the sandbox is still booting" (N); `S/sandbox-push-service.test.ts` "refuses, rather than fakes, a push while the attached sandbox is still booting" (N) | No single race tests both consumers across each gate transition. | +| 6 | Unknown create/capture never proves absence or authorizes replay | `L/vm-resolve.test.ts` "fails once when the allocation remains invisible past the bound, allowing respawn", "fails definitively for another generation", "bounds repeated transient lookup errors without failing the pending generation" (P), "retains the foreground token for an equal-valued bridge claim after inconclusive lookup" (**T1**); `S/sandbox-shutdown.test.ts` "holds a lost VM capture response without retiring the source" (N); `I/sandbox-state-retention.test.ts` "holds an ambiguous legacy snapshot restore across restart instead of invoking it again" (P) | Null foreground resolution is not an absence assertion; foreground/bridge handoff is not exhaustive. | +| 7 | Hold neither adopts nor destroys retained source; explicit continuation | `L/manager.test.ts` "does not run generic termination or replacement while shutdown owns the source" (P); `I/sandbox-state-retention.test.ts` "preserve-stops a persistent provider instead of destroying its only recovery copy" (P); `S/sandbox-shutdown.test.ts` "keeps the hold when the source cannot be stopped for a discard" (N) | Late startup acceptance under an acquired hold has narrower race coverage. | +| 8 | Fresh/restore/resume identity and prebuilt fallback differ | `L/manager.test.ts` "refreshes terminal URL after resume without replacing its token", "does not silently create a fresh sandbox after retained final resume fails", "marks the repo image restore-failed and retries from base when its artifact is unavailable" (P); `L/vm-resolve.test.ts` "resolves an ambiguous base-image retry after a prebuilt image is unavailable" (P) | Retry versus outstanding old bridge/finalizer and token rotation not combined in one test. | +| 9 | Resume/bridge encrypt then conditional commit; only bridge checks reference; fresh/restore writes differ | `S/sandbox-repository.test.ts` "resolves a VM only while its generation and pending handle still match", "atomically records access for the current ... generation", "rejects access encrypted across a ... row", "ordinary resume does not require an expected bridge reference after encryption" (first two P; latter two **T1**); `L/vm-resolve.test.ts` "does not attach bridge access to a newer generation" (P) | Fresh/restore's separate unscoped artifact writes can leak into a successor (separate bug; no safety claim). | +| 10 | Rejection fences/retains, rearm precedes I/O, matching cleanup | `L/rejected-allocation.test.ts` "fences an early connected generation before waiting for mismatch cleanup", "retains rejected cleanup responsibility across restart and failed retirement", "rearms ... cleanup through the assembled alarm handler even under a shutdown hold" (P); "rearms cleanup before stop and does not clear a ... after the old stop succeeds" (**T1**); `S/sandbox-repository.test.ts` "fences ... and persists cleanup responsibility" (P) | Prior-generation replacement retirement has a different unscoped handle clear (separate bug). | +| 11 | Failure writer, classification, breaker dispatch reset | `L/manager.test.ts` "counts an attempt once when the watchdog fails it before the provider rejects it", "handles provider errors and increments failure count for permanent errors", "does not increment circuit breaker for transient errors", "clears the boot-failure streak once a prompt is dispatched to the sandbox" (P); `S/message-queue.test.ts` "does not report a dispatch when the sandbox send fails" (N) | No assembled queue retry during the watchdog/provider double-failure race. | +| 12 | Alarm priority, pinned targets, effect ordering/guards/results | `L/alarm-policy.test.ts` "prioritizes terminal, connect watchdog, stale heartbeat, boot budget, then inactivity"; `L/alarm-effects.test.ts` "heartbeat/inactivity publishes retirement before awaiting snapshot, then uses its required stop/shutdown order", "connecting watchdog/boot budget stops the generation it observed, not a replacement installed mid-alarm" (P); `L/alarm-boot-budget-effects.test.ts` "holds the termination guard only for provider stop, after publishing and detaching" (**T1**) | Guard coverage describes current order only; do not generalize it to other watchdogs. | +| 13 | Lazy logging, background timing, event/deadline/units and failure boundaries | `L/manager.test.ts` "does not invoke collaborators during construction and honors the injected hold first", "derives session_id from getSessionId per use, upgrading once the id changes" (P); `L/pending-vm-respawn.test.ts` "tracks a create/restore's pending handle with ...-second timeout after ... ms setup" (P); `S/alarm/scheduler.test.ts` "retains persisted state when setting the runtime alarm fails" (N) | No exhaustive assertion of every log text or all three modes' best-effort/fatal boundaries. | +| 14 | Transient plaintext auth; expiry/restart cannot reconstruct token | `S/sandbox-repository.test.ts` "sets all spawn fields atomically and invalidates credentials", "stores encrypted credentials and clears them" (P); `L/vm-resolve.test.ts` "reconciles a restarted bridge without blocking readiness or writing a replaced generation", "mints terminal access when the bridge resolves while the original instance holds the token" (P), "retains the foreground token for an equal-valued bridge claim after inconclusive lookup" (**T1**); `L/manager.test.ts` "keeps a resumed sandbox without terminal access when its terminal token is missing/expired" (P) | Old finalizer/new auth overlap still lacks a controlled interleaving; no exhaustive plaintext log assertion. | +| 15 | Conservative lifetime provenance is scheduling, not retirement proof | `L/pending-vm-respawn.test.ts` "tracks a create/restore's pending handle with ...-second timeout after ... ms setup" (P); `S/sandbox-shutdown-safety.test.ts` "verifies provider stop for conservative expiry/legacy expiry without provenance before restoring saved state", "accepts authoritative provider expiry as retirement proof" (N); `L/manager.test.ts` "retires an ambiguously resumed retained object before explicitly retrying it" (P) | No assembled restart interleaving spanning conservative bound, lost recovery response and source retirement. | + +## Separate behavior gaps + +These are _not_ extraction acceptance or claims of fixes. Confirm them with separate +reproducers/issues before behavior changes: + +- Fresh and restore access use `updateSandboxAccess` / `updateSandboxTunnelUrls` without generation + guards after encryption/other awaits; a replacement can receive the old artifact. The T1 real-SQL + tests intentionally characterize only the stronger resume/bridge path. T3 adds the real-SQL + reproducer "characterizes the unguarded fresh/restore artifact write across replacement": + encryption yields, a new identity is reserved, then the old URL and encrypted secret land on the + new row. The access extraction deliberately retains this behavior; a generation-guard fix is + separate work. +- `connection-authenticator.ts` rechecks identity/credentials after `scheduleDisconnectCheck`, but + not status; a same-generation stop during attachment can pass the final check. COL-238 proposes + addressing this but is not landed here. +- `manager.ts` prior-generation `stopPriorProviderSandbox` clears the singleton provider handle + after an await without comparing the replacement's generation/handle. This is distinct from the + rejected-allocation cleanup characterized above. +- Foreground VM auth finalization uses generation **object identity** whereas bridge resolution uses + equal-valued fields. T1 covers an inconclusive foreground lookup handing its token to an + equal-valued bridge observation; an old-finalizer/new-auth overlap still lacks coverage. Treat a + new safety assertion failing on this checkout as a separate bug, not a refactor regression. +- T3 characterizes another existing error boundary: "holds saved resume when its terminal secret + read fails before startup is committed". A successful provider response followed by secret-read + failure still marks the attempt failed and holds saved recovery. This differs from + access-write/publication failures after committed recovery, which retain startup success. No + boundary was moved or fixed. +- Retirement also retains the baseline's capability-based secret clearing rather than the stop + operation's intent. Destructive watchdog/rejection/discard paths on a resumable provider can keep + encrypted credentials. Independent synchronous clearing writes can partially fail, and shutdown's + clear/notify-before-detach sequence can skip detachment on an exception. Explicit intent, atomic + clearing and failure-independent detachment require a separate behavioral fix, not an extraction + claim. `sandbox-access.test.ts` records the inherited failure boundary, not a desired safety rule. +- `providerResumesAfterStop` centralizes the original preserve-stop predicate; it does not check + `resumeSandbox` or authorize recovery. All shipped persistent-resume providers implement resume, + but mismatched provider objects remain possible. Adding that method check would change the + inherited stop/credential policy. Saved retained recovery without the method already holds rather + than spawning fresh; ordinary resume retains its separate fresh fallback. A stronger provider + contract must preserve that distinction in separately approved work. + +## Commands and results + +Node `v24.20.0` satisfies root `engines.node >=24.0.0`; dependencies were installed. Commands ran +sequentially from repository root before test edits: + +| Command | Pre-edit result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| `npm run build -w @open-inspect/shared` | Passed (`tsc`). | +| `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/session/sandbox-access src/session/sandbox-repository` | Passed: 18 files, 600 tests. | +| `npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect session-lifecycle-alarm-recovery sandbox-shutdown sandbox-state-retention` | Passed: 4 files, 55 tests (Workerd provider substitutes, not live provider verification). | +| `npm run typecheck -w @open-inspect/control-plane` | Passed all four TypeScript configs. | +| `npm run test:lint-sandbox-boundaries` | Passed: 2 tests. | +| `git diff --check` | Passed; checkout was clean. | + +After T1 test edits, the targeted 3-file command +`npm test -w @open-inspect/control-plane -- src/session/sandbox-repository.test.ts src/sandbox/lifecycle/rejected-allocation.test.ts src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts` +passed: 98 tests. Its first run failed only because the new guard observation expected two +broadcasts when the existing path emits three; the assertion was corrected to record the actual +baseline sequence. +`npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle/vm-resolve.test.ts` then passed: +21 tests. No production code was changed. Post-edit focused unit tests passed (18 files, 608 tests), +Workerd integration passed (4 files, 55 tests), and control-plane typecheck, boundary lint (2 +tests), targeted Prettier and ESLint checks, and `git diff --check` passed. Shared was built before +dependent checks as recorded above. No full control-plane unit/integration sweep, bundle build, or +live-provider canary was run for T1; those remain later-story verification. + +Additional post-edit command: +`npm test -w @open-inspect/control-plane -- src/session/sandbox-shutdown.test.ts src/session/sandbox-shutdown-safety.test.ts src/session/message-queue.test.ts src/session/sandbox-push-service.test.ts src/session/connection-authenticator.test.ts src/session/alarm src/session/sandbox-events/runtime.handler.test.ts` +passed (8 files, 278 tests). This includes the session-side N entries above; N still records their +pre-edit baseline scope honestly. It is not a full unit-suite run. diff --git a/docs/plans/sandbox-lifecycle-refactor-verification.md b/docs/plans/sandbox-lifecycle-refactor-verification.md new file mode 100644 index 0000000000..e861ade3e0 --- /dev/null +++ b/docs/plans/sandbox-lifecycle-refactor-verification.md @@ -0,0 +1,265 @@ +# Sandbox Lifecycle Refactor: Final Verification + +This is COL-247's closure evidence for [COL-240](https://linear.app/colemurray/issue/COL-240). The +[ownership guide](sandbox-lifecycle-manager-refactor.md) and +[ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md) remain the architecture specification; +the [T1 report](sandbox-lifecycle-refactor-baseline.md) is historical baseline evidence. + +## Revision and Integration + +- Research baseline: `eef911f36e704fc49104546a9cd52b584d8b9223`. +- Audited starting HEAD: `b98a378bdbe2dba1237953da9162e8a85a7e926a`, initially clean. +- Tested checkout: that HEAD plus this PR's boundary rules, composition import comment, regression + tests and documentation. No lifecycle/provider/repository runtime implementation changes in T7. +- Environment: Node `v24.20.0`, installed workspace dependencies, repository root as working + directory. +- Validation status: all required checks passed locally, subject to the existing KV TTL skip and + release/evidence limits below. T7 still requires review/merge. No deployment or live-provider + checks. + +Actual history and combined source were reviewed, not just the watchdog extraction diff: + +| Increment | Integrated Main Commit | Evidence | +| ------------ | ------------------------------------ | --------------------------------------------------------------------------- | +| T1 / COL-241 | `0ce9e9d` (#2144) | Characterization matrix and assembled/real-storage regressions | +| T2 / COL-242 | `44ca1a9` (#2148), `0318fd9` (#2149) | Launch context, then class-based construction | +| T3 / COL-243 | `d3de8c0` (#2151) | Access mechanics and direct shutdown retirement wiring | +| T4 / COL-244 | `64aa395` (#2166) | Five VM fields and reconciliation moved together | +| T5 / COL-245 | `60930ce` (#2170) | Stateless cleanup and shared bounded-stop operation | +| T6 / COL-246 | `b98a378` (#2172) | Stateless watchdog effects; complete boot-budget operation stays in manager | +| T7 / COL-247 | This PR, review/merge pending | Combined audit, boundary checks, wiring regressions and full validation | + +## State and Dependency Owners + +Paths below are relative to `packages/control-plane/src/`. + +| State or Responsibility | Sole Owner | +| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `isSpawningSandbox`, `isTerminatingSandbox`, `providerStartupPending` | `sandbox/lifecycle/manager.ts` | +| Lazy `logMemo` | Manager; collaborators receive a lazy logger operation | +| `bridgeResolution`, `bridgeRetryGeneration`, `bridgeStartupClaim`, `bridgeResolvedStartup`, `vmStartupAuth` | `vm-startup-reconciliation.ts`; foreground object identity remains distinct from bridge field equality | +| Launch inputs/settings/images | Readonly `SandboxLaunchContext`; no lifecycle flags, provider operations or authorization | +| Access signing/reuse/retirement/notification | `SandboxAccess`; no retained signing key, eligibility or lifecycle state | +| Rejected/late cleanup and local stop bound | Stateless `allocation-cleanup.ts` and `provider-stop.ts`; no new durable record | +| Connect/heartbeat/snapshot/inactivity effects | Stateless `watchdog-effects.ts`; no flags, scheduler or full manager reference | +| Conditional SQL and encryption | `session/sandbox-repository.ts`, including atomic post-encryption resume/bridge checks | +| Durable checkpoint/shutdown/holds/receipts/recovery | `SandboxShutdownCoordinator` and `sandbox-shutdown-repository.ts` | +| Shared pending/in-flight deadlines | `session/alarm/scheduler.ts`, not extracted collaborators | + +Manager retains startup selection/orchestration, synchronous identity reservation, generic provider +claims, rejection policy, breaker/error ownership, readiness and queue/push admission, public +recovery, cancellation/archive policy, prior-generation retirement, fatal/unresponsive termination, +alarm capture/dispatch and the entire boot-budget effect. Only explicit boot-budget provider stop +owns that path's termination-guard interval, after publication and detach. No universal mutex was +introduced. + +Composition still constructs repository/socket/messenger leaves, access, shutdown, then manager. +Shutdown calls access retirement directly, not a manager forwarding callback. Constructors do not +invoke runtime work; deferred queue callbacks run after construction. VM's only reverse manager +operation is resolved startup acceptance. Watchdog callbacks expose named manager-owned operations, +not mutable context or flag setters. Repositories implement internal contracts structurally. + +Consumer ports remain separate from internal contracts. Authenticator, runtime handlers, queue, +execution stop, push, HTTP lifecycle, access readers and alarm handling retain their existing public +ports. Cloudflare and Node compose the same application graph through platform ports. No extraction +introduced full-manager consumer access or a second lifecycle/durable shutdown authority. + +## Compatibility and Traces + +No T7 public API, SQL schema, stored/wire shape, runtime/backend protocol, timeout, retry, setting, +log-event/message or provider-stop argument changes. Manager constructor wiring and internal type +locations intentionally changed in earlier extractions; they are not external consumer APIs. +Consumer `ports.ts`, lifecycle adapters, production sandbox repository, pure decisions/alarm policy, +platform ports and both platform entry points are unchanged from the research baseline. + +The combined audit preserves these trace boundaries: + +- Reservation commits sandbox identity and shutdown ownership synchronously, announces after commit, + invalidates credentials before hashing, and conditionally publishes the hash before launch reads. +- Fresh retains environment/image/MCP/Slack order; restore retains environment/Slack/MCP order and + pending registration immediately before synchronous recovery-invoked recording/provider dispatch. + Resume gains no unrelated launch inputs. Confirmed-unavailable prebuilt retry rotates + identity/auth. +- Unknown VM outcomes remain lookup-only; nullable foreground outcomes authorize neither replay nor + destruction. Foreground finalization retains generation object identity and deferred token + handoff. +- Provider completion, attachment, runtime readiness, lifetime, generation acknowledgement, queue + admission and live push remain distinct. A held-current result is neither adopted nor destroyed. +- Resume/bridge encrypt before atomic eligibility checks; only bridge supplies expected reference. + Fresh/restore retain separate artifact writes. Terminal expiry/hash-only restart cannot renew + JWTs. +- Rejection fences and retains cleanup before I/O. Shared retry is persisted before stop, matching + completion clears only its handle, and rejected cleanup precedes shutdown holds/watchdogs. +- Watchdogs retain captured generation checks, path-specific snapshot/stop/send/fence ordering, + return values and queue redrive. Breaker charging stays with the failure writer and resets only + after successful prompt dispatch. Logger/background resolution remains lazy/synchronously started + at the original entry points. Local abort/timeout does not establish remote cancellation. + +Two explicit reviewed fixes in earlier extraction PRs are exceptions to a purely mechanical claim: +COL-244 suppresses access announcement when deferred acceptance is refused; COL-245 requires an +actually dispatched, successful stop before confirmation-dependent cleanup/replacement. They are +documented in the ownership guide, not attributed to T7 or claimed to close broader safety gaps. + +## Executed Evidence + +Existing named evidence is retained in the T1 matrix and ownership guide, including assembled +manager launch/VM/cleanup/watchdog tests, real SQL/encryption tests, Workerd +recovery/early-connect/shutdown tests, scheduler/handler tests and Node in-memory/file-backed +storage conformance. + +T7 adds only missing wiring or explicit inherited-gap reproducers: + +- `test/integration/sandbox-vm-reconciliation.test.ts`: production runtime reconstructed over an + actual foreground pending reservation. Real encryption is gated after ciphertext creation; + changing only expected reference or reservation timestamp rejects completion without row/shutdown + mutation or publication. Success retains early readiness/conservative lifetime, decryptable access + and no restarted terminal signing authority. Admission is tested separately from lookup + completion. +- `test/integration/sandbox-state-retention.test.ts`: reconstructed rejected cleanup now runs + through production scheduled delivery and both shutdown-priority passes. Retry exists during + provider I/O; failure preserves handle/hold/receipt; success conditionally clears; duplicate + delivery does not stop again; the in-flight deadline is acknowledged. +- `src/sandbox/lifecycle/alarm-effects.test.ts`: two controlled assembled characterization traces + record inherited successor retirement/absent-handle retargeting and post-stop failure publication. +- `scripts/lint-sandbox-boundaries.test.mjs`: existing ESLint checks now include launch-context and + startup-errors, extension-bearing imports and permitted internal uses; no bespoke scanner/rule + weakening. Composition's launch-port import has an explicit, justified exemption. + +The full results below record the initial `3b9e7a0` handoff; the test-only review follow-up has +focused validation recorded separately below. Checks ran sequentially, with one Vitest worker to +avoid starving the sandbox. Log redirection under `/tmp/opencode/col247-*.log` does not alter the +commands or selections below. + +| Exact Command | Exit / Result | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `npm run build -w @open-inspect/shared` | 0; passed | +| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 0; 359 files / 6,114 tests passed (126.13 s) | +| `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1` | Terminal timeout at 600 s; no completion result, not a pass | +| `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1 --reporter=verbose` | 0; 137 files / 1,742 tests passed, 1 existing skip (final rerun 647.39 s) | +| `npm run typecheck -w @open-inspect/control-plane` | 0; Worker, Node, unit and integration configurations passed | +| `npm run build -w @open-inspect/control-plane` | 0; Worker and Node bundles passed (4.8 MB / 6.6 MB; esbuild size warnings) | +| `npm run lint -w @open-inspect/control-plane` | 0; no errors or warnings | +| `npm run test:lint-sandbox-boundaries` | 0; 2 tests passed | +| `npm run format:check` | 0; entire repository passed | +| `git diff --check` | 0; passed | + +The Workerd skip is `cache-store-conformance.test.ts`, Cloudflare KV "reads null once the TTL has +passed": that backend does not offer the suite's controllable clock. It is preexisting and unrelated +to lifecycle verification; no new skip was introduced. No required command remains blocked. Workerd +prints deliberate eviction-test exceptions and NDJSON content-type warnings without test failures. +All application suites passed; the first full integration attempt needed a longer terminal budget, +not a test fix. The successful retry took 760.32 s. Final review moved deadline/hold assertions +outside the cleanup provider stub so intentional provider-error catches cannot swallow assertions on +failed attempts. The entire integration suite was then rerun successfully against that final test +version (647.39 s); no source/test edits followed that pass before the initial PR handoff. + +Additional validation: + +- `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle/alarm-effects.test.ts --maxWorkers=1`: + exit 0, 1 file / 28 tests. +- `npm run test:integration -w @open-inspect/control-plane -- sandbox-vm-reconciliation sandbox-state-retention --maxWorkers=1`: + first exit 1 (3 new VM tests failed because fixture initialization retained a recent spawn + timestamp, correctly invoking the existing spawn-throttle gate; 35 retention tests passed). The + fixture resets its predecessor timestamp before the actual foreground reservation. Rerun exit 0, 2 + files / 38 tests. No production change or baseline suite failure was involved. +- `npx eslint eslint.config.js scripts/lint-sandbox-boundaries.test.mjs packages/control-plane/test/integration/sandbox-state-retention.test.ts packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts`: + exit 0; checks the edited configuration/scripts/integration tests outside the package's + `eslint src/` selection. All four typecheck configurations were also rerun after final assertion + refinement. + +## PR Review Follow-up + +The test-only follow-up to `3b9e7a0` uses `ModalVmStartupError("unknown", ...)`, settles the +original foreground create inside the assertions, and checks persisted state afterward. Refusal +cases change the reservation timestamp or replace its pending reference with another session's +pending reference; foreground completion must not mutate or publish. Success performs a second +lookup, not a second create, and preserves early readiness and conservative lifetime. Only the +still-live original instance can subsequently mint terminal access; the reconstructed graph cannot +recover that key. Named VM-resolution task selection no longer constrains unrelated background work. + +Rejected cleanup asserts every explicit handle, destructive intent, reason and bounded signal +outside the provider stub. A controlled `Date.now()` reaches each persisted deadline; the test +verifies the real host alarm, consumes it before each simulated platform callback, and verifies +replacement host arming after failure. Success retains the pre-I/O retry as designed; its final due +no-op delivery drains both host and persisted deadlines without stopping again. This models host +delivery semantics, not automatic Workerd clock advancement or live-provider timing. + +Validation uses the existing focused commands rather than repeating full application sweeps or +bundles for test/documentation-only changes. An initial new assertion incorrectly equated foreground +launch-config and narrower bridge lookup shapes; it was corrected to pin their common identity and +timeout fields, without changing production behavior. + +| Follow-up Command | Result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ | +| `npm run build -w @open-inspect/shared` | Passed | +| `npm run test:integration -w @open-inspect/control-plane -- sandbox-vm-reconciliation sandbox-state-retention --maxWorkers=1` | 2 files / 38 tests passed | +| `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle/vm-resolve.test.ts src/sandbox/lifecycle/rejected-allocation.test.ts src/session/alarm/scheduler.test.ts --maxWorkers=1` | 3 files / 71 tests passed | +| `npm run typecheck -w @open-inspect/control-plane` | All four configurations passed | +| `npx eslint packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts packages/control-plane/test/integration/sandbox-state-retention.test.ts` | Passed | +| `npm run format:check` | Whole repository passed | +| `git diff --check` | Passed | + +## Related Work + +At the audited checkout, COL-130/151/155/156/159 remain Backlog; existing socket names, boot-phase +helpers, shutdown phase projection, construction-policy defaults and old contracts are retained, not +opportunistically renamed/redesigned/deleted. COL-161 and COL-238 remain In Progress; spawn-time +context injection and PR #2141's heartbeat confirmation are not imported. COL-150's pure alarm +policy is reused. These issues are not closed, reparented or otherwise absorbed by this +verification. + +Separately landed work since the research baseline includes Modal VM Docker-preparation/error/tunnel +lookup fixes (#2136/#2138/#2139), Modal save/termination handling (#2146), held-save alarm retries +(`8ed1aea`, #2152), model-catalog additions (#2147), identity/team authorization/secrets/grants and +repository-scoped installation credentials (#2179/#2180/#2181), and COL-226 restore credentials +(`f1cf069`, #2178). Their behavior is preserved and distinguished from extraction; +shared/web/backend contracts are not changed in T7. Recheck concurrent work at merge, not by +restoring the old baseline. + +## Separate Gaps and Evidence Limits + +Green characterization tests preserve current behavior; they do not endorse it as safe. Follow-up +behavior work should use scoped authorization and regressions, not silently extend this refactor: + +- **Unmanaged ownership await and absent target:** the new inactivity reproducer replaces the row + while `requestShutdown()` waits. On `unmanaged`, old work retires successor access/status and an + absent captured handle falls back to the successor handle with the old timestamp. Follow up with + immediate generation/ownership revalidation and an explicit-target stop contract preserving + absence. +- **Connect-timeout publication:** the new stop-gate reproducer installs a ready replacement. Old + completion persists its error on that row and broadcasts failure despite the row staying ready. + Follow up with generation-scoped failure persistence/publication, not a universal teardown guard. +- **Fresh/restore access:** the existing real-SQL encryption/replacement reproducer shows unguarded + per-artifact writes landing on a successor. Separately design generation-conditional artifact + writes. +- **Prior retirement:** captured prior-handle stop followed by a replaced row can clear the current + singleton handle. This remains distinct from matching rejected-cleanup completion; follow up with + conditional clearing under the existing confirmation-required/best-effort policy. +- **Inherited partial boundaries:** access-clear/notify exceptions can skip detach; saved-resume + secret-read failure before commit holds successful provider recovery; bridge access can commit + before held lifecycle acceptance; pending sandbox/shutdown handle registration uses separate + writes. Existing characterization and fault traces are not atomicity guarantees. Address each + separately. +- **Scheduling/attachment:** inactivity warnings retain their full grace interval rather than the + healthy-path heartbeat cap; final attachment identity recheck does not check status. Existing + policy characterization and COL-238 remain separate work, not universal liveness guarantees. + +Remaining evidence limits: no single assembled real-storage old-auth/second-reservation/hash race; +no actual base-image retry combined with an outstanding old bridge across hash publication; no full +Node-host lifecycle/alarm transport test beyond current runtime/storage conformance; no exhaustive +Workerd boot-budget/heartbeat-to-queue matrix. Existing complementary assembled/storage tests remain +useful but are not stronger combined-race or backend guarantees. The new VM tests use substituted +socket transport and public attachment/readiness operations, not the full upgrade/authenticator +path. + +## Release and Rollback + +Normal review and merge remain required; T7 is not yet integrated and this report does not close the +parent before merge. No schema migration, feature flag or deployment is required by extraction. No +provider canary, exactly-once execution, remote cancellation or guaranteed recovery is claimed. + +If maintainers release, inspect existing launch/ready latency, breakers, access, rejected-cleanup +retries, holds and shared alarms. Provider-backed canaries are a release decision, not evidence from +Workerd substitutes. Roll back a coherent reviewed increment with dependent increments and +separately landed fixes accounted for; do not use an arbitrary old binary or destructive history +reset. diff --git a/docs/schemas/trace-export.v2.schema.json b/docs/schemas/trace-export.v2.schema.json new file mode 100644 index 0000000000..58e642cc7f --- /dev/null +++ b/docs/schemas/trace-export.v2.schema.json @@ -0,0 +1,330 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "schemaVersion": { "type": "number", "const": 2 }, + "type": { "type": "string", "const": "session" }, + "id": { "type": "string" }, + "title": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "status": { + "type": "string", + "enum": ["created", "active", "completed", "failed", "archived", "cancelled"] + }, + "source": { + "type": "string", + "enum": ["user", "agent", "automation", "github-bot", "linear-bot", "slack-bot"] + }, + "spawnSource": { + "type": "string", + "enum": ["user", "agent", "automation", "github-bot", "linear-bot", "slack-bot"] + }, + "parentSessionId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "rootSessionId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "spawnDepth": { "type": "number" }, + "harness": { "type": "string", "enum": ["opencode", "claude", "codex"] }, + "repoOwner": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "repoName": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "baseBranch": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "model": { "type": "string" }, + "provider": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "reasoningEffort": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "userId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "scmLogin": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "automationId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "automationRunId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "environmentId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "messageCount": { "type": "number" }, + "prCount": { "type": "number" }, + "totalCost": { "type": "number" }, + "activeDurationMs": { "type": "number" }, + "inputTokens": { "type": "number" }, + "outputTokens": { "type": "number" }, + "reasoningTokens": { "type": "number" }, + "cacheReadTokens": { "type": "number" }, + "cacheWriteTokens": { "type": "number" }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoOwner": { "type": "string" }, + "repoName": { "type": "string" }, + "repoId": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "baseBranch": { "type": "string" } + }, + "required": ["repoOwner", "repoName", "repoId", "baseBranch"] + } + }, + "pullRequests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoOwner": { "type": "string" }, + "repoName": { "type": "string" }, + "prNumber": { "type": "number" }, + "url": { "type": "string" }, + "lifecycleState": { "type": "string", "enum": ["open", "closed", "merged"] }, + "isDraft": { "type": "boolean" }, + "headBranch": { "type": "string" }, + "baseBranch": { "type": "string" }, + "headSha": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "providerCreatedAt": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "mergedAt": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "closedAt": { "anyOf": [{ "type": "number" }, { "type": "null" }] } + }, + "required": [ + "repoOwner", + "repoName", + "prNumber", + "url", + "lifecycleState", + "isDraft", + "headBranch", + "baseBranch", + "headSha", + "providerCreatedAt", + "mergedAt", + "closedAt" + ] + } + }, + "createdAt": { "type": "number" }, + "updatedAt": { "type": "number" }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "authorId": { "type": "string" }, + "content": { "type": "string" }, + "source": { + "type": "string", + "enum": ["web", "slack", "linear", "extension", "github", "automation", "agent"] + }, + "attachments": { + "anyOf": [ + { + "maxItems": 6, + "type": "array", + "items": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9-]+$" + }, + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "mimeType": { + "type": "string", + "enum": ["image/png", "image/jpeg", "image/webp", "image/gif"] + } + }, + "required": ["attachmentId", "name", "mimeType"], + "additionalProperties": false + } + }, + { "type": "null" } + ] + }, + "status": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"] + }, + "createdAt": { "type": "number" }, + "startedAt": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "completedAt": { "anyOf": [{ "type": "number" }, { "type": "null" }] } + }, + "required": [ + "id", + "authorId", + "content", + "source", + "attachments", + "status", + "createdAt", + "startedAt", + "completedAt" + ] + } + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { + "type": "string", + "enum": [ + "heartbeat", + "ready", + "sandbox_generation_ready", + "preservation_prepared", + "token", + "tool_call", + "step_start", + "step_finish", + "tool_result", + "git_sync", + "error", + "execution_complete", + "context_compacted", + "artifact", + "push_complete", + "push_error", + "warning", + "boot_progress", + "session_title", + "snapshot_ready", + "user_message" + ] + }, + "data": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": {} + }, + "messageId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "createdAt": { "type": "number" }, + "timelineSequence": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } + }, + "required": ["id", "type", "data", "messageId", "createdAt", "timelineSequence"] + } + }, + "usage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1 }, + "messageId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "model": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "harness": { + "anyOf": [ + { "type": "string", "enum": ["opencode", "claude", "codex"] }, + { "type": "null" } + ] + }, + "inputTokens": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "outputTokens": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "reasoningTokens": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "cacheReadTokens": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "cacheWriteTokens": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "totalTokens": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "stepCostUsd": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "messageCostUsd": { "anyOf": [{ "type": "number" }, { "type": "null" }] }, + "isSubtask": { "type": "boolean" }, + "childSessionId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "taskCallId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "reason": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "createdAt": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } + }, + "required": [ + "id", + "messageId", + "model", + "harness", + "inputTokens", + "outputTokens", + "reasoningTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalTokens", + "stepCostUsd", + "messageCostUsd", + "isSubtask", + "childSessionId", + "taskCallId", + "reason", + "createdAt" + ] + } + } + }, + "required": [ + "schemaVersion", + "type", + "id", + "title", + "status", + "source", + "spawnSource", + "parentSessionId", + "rootSessionId", + "spawnDepth", + "harness", + "repoOwner", + "repoName", + "baseBranch", + "model", + "provider", + "reasoningEffort", + "userId", + "scmLogin", + "automationId", + "automationRunId", + "environmentId", + "messageCount", + "prCount", + "totalCost", + "activeDurationMs", + "inputTokens", + "outputTokens", + "reasoningTokens", + "cacheReadTokens", + "cacheWriteTokens", + "repositories", + "pullRequests", + "createdAt", + "updatedAt" + ] + }, + { + "type": "object", + "properties": { + "schemaVersion": { "type": "number", "const": 2 }, + "type": { "type": "string", "const": "session_error" }, + "sessionId": { "type": "string" }, + "reason": { "type": "string", "const": "http_error" }, + "status": { "type": "number" } + }, + "required": ["schemaVersion", "type", "sessionId", "reason", "status"] + }, + { + "type": "object", + "properties": { + "schemaVersion": { "type": "number", "const": 2 }, + "type": { "type": "string", "const": "session_error" }, + "sessionId": { "type": "string" }, + "reason": { + "type": "string", + "enum": ["runtime_failure", "page_cap_reached", "trace_budget_exceeded"] + } + }, + "required": ["schemaVersion", "type", "sessionId", "reason"] + }, + { + "type": "object", + "properties": { + "schemaVersion": { "type": "number", "const": 2 }, + "type": { "type": "string", "const": "cursor" }, + "nextCursor": { "type": "string" } + }, + "required": ["schemaVersion", "type", "nextCursor"] + }, + { + "type": "object", + "properties": { + "schemaVersion": { "type": "number", "const": 2 }, + "type": { "type": "string", "const": "error" } + }, + "required": ["schemaVersion", "type"] + } + ] +} diff --git a/eslint.config.js b/eslint.config.js index 119ece997b..b4acc8a096 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,54 @@ import reactHooksPlugin from "eslint-plugin-react-hooks"; import eslintConfigPrettier from "eslint-config-prettier"; import globals from "globals"; +const sandboxImplementationImports = [ + { + regex: "(?:^|/)sandbox-repository(?:\\.[cm]?[jt]sx?)?$", + message: + "Only session composition constructs SandboxRepository. Consumers use sandbox-ports; lifecycle effects use their storage port.", + }, + { + regex: "(?:^|/)lifecycle/manager(?:\\.[cm]?[jt]sx?)?$", + importNames: ["SandboxLifecycleManager"], + message: + "Only session composition constructs the lifecycle manager. Consumers depend on focused lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/launch-context(?:\\.[cm]?[jt]sx?)?$", + message: "Launch inputs are internal to lifecycle composition. Consumers use lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/sandbox-access(?:\\.[cm]?[jt]sx?)?$", + message: + "Access mechanics are internal to lifecycle composition. Consumers use lifecycle ports and session access readers.", + }, + { + regex: "(?:^|/)lifecycle/vm-startup-reconciliation(?:\\.[cm]?[jt]sx?)?$", + message: + "VM startup reconciliation is internal to the lifecycle manager. Consumers use lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/allocation-cleanup(?:\\.[cm]?[jt]sx?)?$", + message: + "Allocation cleanup is internal to the lifecycle manager. Consumers use lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/provider-stop(?:\\.[cm]?[jt]sx?)?$", + message: + "Bounded provider-stop mechanics are internal to lifecycle. Consumers use lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/startup-errors(?:\\.[cm]?[jt]sx?)?$", + message: + "Startup abandonment errors are internal to lifecycle. Consumers use lifecycle outcomes.", + }, + { + regex: "(?:^|/)lifecycle/watchdog-effects(?:\\.[cm]?[jt]sx?)?$", + message: + "Watchdog effects are internal to the lifecycle manager. Consumers use lifecycle ports.", + }, +]; + export default tseslint.config( // Global ignores { @@ -12,6 +60,7 @@ export default tseslint.config( "**/node_modules/**", "**/dist/**", "**/.next/**", + "**/.source/**", "**/build/**", "**/.wrangler/**", "**/coverage/**", @@ -227,6 +276,7 @@ export default tseslint.config( "error", { patterns: [ + ...sandboxImplementationImports, { // Last-segment match: covers any relative depth (./, ../, ../../) // and extension-bearing specifiers. The basename is unique in @@ -265,6 +315,7 @@ export default tseslint.config( "error", { patterns: [ + ...sandboxImplementationImports, { regex: "(?:^|/)components(?:\\.[cm]?[jt]sx?)?$", message: @@ -287,6 +338,7 @@ export default tseslint.config( "error", { patterns: [ + ...sandboxImplementationImports, { regex: "(?:^|/)durable-object(?:\\.[cm]?[jt]sx?)?$", message: @@ -306,6 +358,7 @@ export default tseslint.config( "error", { patterns: [ + ...sandboxImplementationImports, { // Last-segment match: covers any relative depth (./, ../, ../../) // and extension-bearing specifiers. The basename is unique in @@ -328,9 +381,15 @@ export default tseslint.config( }, }, - // React-specific configuration for web package + // The Cloudflare host composes the session runtime, not individual implementations. + { + files: ["packages/control-plane/src/cloudflare/durable-object.ts"], + rules: { "no-restricted-imports": ["error", { patterns: sandboxImplementationImports }] }, + }, + + // React-specific configuration for browser packages { - files: ["packages/web/**/*.{ts,tsx}"], + files: ["packages/{docs,web}/**/*.{ts,tsx}"], plugins: { react: reactPlugin, "react-hooks": reactHooksPlugin, @@ -400,6 +459,19 @@ export default tseslint.config( ], }, }, + { + files: ["packages/web/src/**/*.tsx"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: 'JSXOpeningElement[name.name="select"]', + message: + "Use Select / SelectTrigger / SelectContent / SelectItem from @/components/ui/select instead of a native setNameSearch(event.target.value)} + className="pl-9" + /> + + )} - - -
-
- - {actionError && ( - - {actionError} - - )} - - {error && ( - -
- Failed to load automations. - -
-
- )} - - {loading ? ( -
-
-
- ) : automations.length > 0 || !error ? ( - handleAction(id, "pause")} - onResume={(id) => handleAction(id, "resume")} - onTrigger={(id) => handleAction(id, "trigger")} - onDelete={(id) => handleAction(id, "delete")} - /> - ) : null} - - {(hasMore || loadingMore) && !loading && ( -
- -
- )} +
diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx index c99c56086e..c848690d90 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx @@ -9,46 +9,67 @@ import AutomationTemplatesPage from "./page"; expect.extend(matchers); let canCreate = true; +let search = ""; const replace = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ replace }), + useSearchParams: () => new URLSearchParams(search), })); +vi.mock("@/hooks/use-teams", () => ({ + useMeTeams: () => ({ + teams: [{ id: "team-1" }, { id: "team-2" }, { id: "team/one" }], + loading: false, + error: undefined, + }), +})); vi.mock("@/hooks/use-current-user-authorization", () => ({ useCurrentUserAuthorization: () => ({ - hasPermission: (permission: string) => permission === "automations.create" && canCreate, + hasPermission: (permission: string) => + canCreate && (permission === "automations.create" || permission === "sessions.create"), loading: false, }), })); vi.mock("@/components/sidebar-layout", () => ({ CollapsedSidebarControls: () => null, - useSidebarContext: () => ({ isOpen: true }), -})); - -vi.mock("@/components/automations/template-gallery", () => ({ - TemplateGallery: () =>
Template gallery
, + useSidebarContext: () => ({ isOpen: false }), })); beforeEach(() => { canCreate = true; + search = ""; replace.mockReset(); }); afterEach(cleanup); describe("AutomationTemplatesPage", () => { - it("renders templates with automations.create", () => { + it("preserves scope for template creation and return links", () => { + search = "teamId=team%2Fone"; + render(); + expect(screen.getByRole("link", { name: "Back to automations" })).toHaveAttribute( + "href", + "/automations?teamId=team%2Fone" + ); + expect(screen.getByRole("link", { name: "Add Find bugs" })).toHaveAttribute( + "href", + "/automations/new?template=find-bugs&teamId=team%2Fone" + ); + }); + + it("renders templates with automation and session creation permissions", () => { render(); expect(screen.getByRole("heading", { name: "Automation templates" })).toBeInTheDocument(); }); - it("redirects a direct template link without automations.create", () => { + it.each(["", "?teamId=team%2Fone"])("redirects a denied template link with scope %s", (query) => { + search = query.slice(1); canCreate = false; render(); - expect(replace).toHaveBeenCalledWith("/automations"); + expect(replace).toHaveBeenCalledWith(`/automations${query}`); expect(screen.queryByRole("heading", { name: "Automation templates" })).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx index 56d3936c9f..6a627b6c35 100644 --- a/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx @@ -1,22 +1,31 @@ "use client"; import Link from "next/link"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { TemplateGallery } from "@/components/automations/template-gallery"; import { BackIcon } from "@/components/ui/icons"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useAutomationScope } from "@/hooks/use-automation-scope"; +import { useCanCreateAutomation } from "@/hooks/use-can-create-automation"; export default function AutomationTemplatesPage() { + return ( + + + + ); +} + +function AutomationTemplatesContent() { const { isOpen } = useSidebarContext(); const router = useRouter(); - const { hasPermission, loading } = useCurrentUserAuthorization(); - const canCreate = hasPermission("automations.create"); + const { teamId, navigation } = useAutomationScope(); + const { canCreate, loading } = useCanCreateAutomation(teamId); useEffect(() => { - if (!loading && !canCreate) router.replace("/automations"); - }, [canCreate, loading, router]); + if (!loading && !canCreate) router.replace(navigation.list); + }, [canCreate, loading, navigation, router]); if (loading || !canCreate) return null; @@ -27,7 +36,7 @@ export default function AutomationTemplatesPage() {
@@ -49,7 +58,7 @@ export default function AutomationTemplatesPage() {

- + diff --git a/packages/web/src/app/(app)/(sidebar)/layout.tsx b/packages/web/src/app/(app)/(sidebar)/layout.tsx index 64720bb706..37fea7e527 100644 --- a/packages/web/src/app/(app)/(sidebar)/layout.tsx +++ b/packages/web/src/app/(app)/(sidebar)/layout.tsx @@ -1,5 +1,10 @@ import { SidebarLayout } from "@/components/sidebar-layout"; +import { ActiveTeamProvider } from "@/hooks/use-active-team"; export default function SidebarAppLayout({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx b/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx new file mode 100644 index 0000000000..e34c88eaf2 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx @@ -0,0 +1,630 @@ +// @vitest-environment jsdom +/// + +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { TeamResponse } from "@/hooks/use-teams"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; +import { environment, mocks, repo, sessionCreateBody } from "./page.test-fixture"; +import Home from "./page"; + +// Radix Select uses pointer-capture APIs that jsdom doesn't implement. +beforeAll(() => { + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; +}); + +async function selectTeam(user: ReturnType, name: string) { + await user.click(screen.getByRole("combobox", { name: "Team context" })); + await user.click(await screen.findByRole("option", { name })); +} + +async function selectAudience(user: ReturnType, label: string) { + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await user.click(screen.getByRole("radio", { name: label })); +} + +function team(overrides: Partial = {}): TeamResponse & { role: TeamRole } { + return { + id: "team-1", + slug: "engineering", + name: "Engineering", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + memberCount: 1, + role: "member", + ...overrides, + }; +} + +describe("Home team context", () => { + it("preselects a required team when the setting changes without widening draft visibility", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + const view = render(); + await selectAudience(user, "Private"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "private" }) + ); + mocks.requireTeamOnCreate = true; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Private; team context: Engineering" + ); + expect(screen.getByRole("button", { name: /send/i })).not.toBeDisabled(); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: "team-1", + visibility: "private", + }); + }); + + it("cancels deferred warming when the prompt is cleared before readiness", async () => { + const user = userEvent.setup(); + mocks.teamsLoading = true; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await user.clear(input); + mocks.teamsLoading = false; + view.rerender(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it.each(["models", "provider accounts"])( + "consumes deferred input after %s become ready", + async (resource) => { + const user = userEvent.setup(); + mocks.enabledModelsLoadingValue = resource === "models"; + mocks.providerAccountsLoadingValue = resource === "provider accounts"; + const view = render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + expect(fetch).not.toHaveBeenCalled(); + mocks.enabledModelsLoadingValue = false; + mocks.providerAccountsLoadingValue = false; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + } + ); + + it.each([ + [undefined, "workspace"], + [undefined, "all"], + ["workspace", undefined], + ["workspace", "all"], + ["all", undefined], + ["all", "workspace"], + ] as const)( + "reconciles composer access when aggregate scope changes from %s to %s", + async (from, to) => { + const user = userEvent.setup(); + mocks.teams = [team()]; + mocks.scope = from; + const view = render(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Engineering"); + await user.keyboard("{Escape}"); + await selectAudience(user, "Private"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "private" }) + ); + + mocks.scope = to; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + mocks.scope = from; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: null, + visibility: "workspace", + }); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + } + ); + + it("re-warms a retired draft on continued input without duplicating session creation", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await waitFor(() => expect(sessionCreateBody()).toBeDefined()); + await selectAudience(user, "Private"); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.type(input, " again"); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ visibility: "private" }); + }); + }); + + it("consumes deferred warming once memberships become ready without further input", async () => { + const user = userEvent.setup(); + mocks.teamsLoading = true; + mocks.teams = [team()]; + mocks.requireTeamOnCreate = true; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + expect(fetch).not.toHaveBeenCalled(); + mocks.teamsLoading = false; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + }); + + it("preselects the first required team locally without changing All my teams", async () => { + mocks.teams = [team()]; + mocks.requireTeamOnCreate = true; + const user = userEvent.setup(); + render(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Engineering team; team context: Engineering" + ); + expect(mocks.activeTeamId).toBeNull(); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + }); + + it("archives a draft on model change without creating its replacement until submit", async () => { + mocks.enabledModelsValue.push("openai/gpt-5.4"); + mocks.enabledModelOptionsValue.push({ + category: "OpenAI", + models: [ + { + id: "openai/gpt-5.4", + name: "GPT-5.4", + description: "", + }, + ], + }); + const user = userEvent.setup(); + render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => expect(sessionCreateBody()).toBeDefined()); + await user.click(screen.getByRole("button", { name: "Switch model to GPT-5.4" })); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ model: "openai/gpt-5.4" }); + }); + + it("falls back locally when the composer's team is no longer an active membership", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + const view = render(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Engineering"); + await user.keyboard("{Escape}"); + await selectAudience(user, "Engineering team"); + mocks.teams = []; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + }); + + it("separates icon-led session access from the agent controls", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + render(); + const trigger = screen.getByRole("button", { + name: "Session access: Workspace; team context: No team", + }); + expect(trigger).toHaveTextContent("Workspace"); + expect(trigger.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument(); + await user.click(trigger); + const context = screen.getByRole("dialog", { name: "Session access" }); + expect(within(context).getByRole("combobox", { name: "Team context" })).toHaveTextContent( + "No team" + ); + expect( + within(context).getByRole("radiogroup", { name: "Session audience" }) + ).toBeInTheDocument(); + expect( + within(context).queryByRole("button", { name: /model and effort/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Manage secrets and settings" }) + ).not.toBeInTheDocument(); + }); + + it("defaults workspace drafts to workspace visibility with team visibility disabled", async () => { + const user = userEvent.setup(); + render(); + const visibility = screen.getByRole("button", { name: /^Session access: Workspace;/ }); + expect(visibility.tagName).toBe("BUTTON"); + expect(visibility).toHaveTextContent("Workspace"); + await user.click(visibility); + const menu = screen.getByRole("radiogroup", { name: "Session audience" }); + expect(within(menu).getByRole("radio", { name: "Workspace" })).toBeChecked(); + expect(within(menu).getByRole("radio", { name: "Team" })).toBeDisabled(); + await user.keyboard("{Escape}"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + }); + + it("uses the active team's defaults and archives the warm draft on visibility and team changes", async () => { + const user = userEvent.setup(); + mocks.teams = [team(), team({ id: "team-2", name: "Design", defaultVisibility: "workspace" })]; + mocks.activeTeamId = "team-1"; + const view = render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + await selectAudience(user, "Workspace"); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.clear(screen.getByPlaceholderText("What do you want to build?")); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(2) + ); + const workspaceCall = vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url) === "/api/sessions")[1]; + expect(JSON.parse(String(workspaceCall[1]?.body))).toMatchObject({ + teamId: "team-1", + visibility: "workspace", + }); + mocks.activeTeamId = "team-2"; + view.rerender(); + expect( + screen.getByRole("button", { name: "Session access: Workspace; team context: Design" }) + ).toHaveTextContent("Workspace"); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(2); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(3); + expect(JSON.parse(String(calls[2][1]?.body))).toMatchObject({ + teamId: "team-2", + visibility: "workspace", + }); + }); + expect( + vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url) === "/api/sessions/session-1/archive") + ).toHaveLength(2); + }); + + it("offers a composer team choice, preserves Workspace access, and maps No team to null", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + render(); + const trigger = screen.getByRole("button", { name: /^Session access:/ }); + expect(trigger.tagName).toBe("BUTTON"); + expect(trigger).toHaveTextContent("Workspace"); + await user.click(trigger); + await selectTeam(user, "Engineering"); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect(mocks.activeTeamId).toBeNull(); + expect(trigger).toHaveAccessibleName("Session access: Workspace; team context: Engineering"); + expect(screen.getByRole("combobox", { name: "Team context" })).toHaveTextContent("Engineering"); + await selectTeam(user, "No team"); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect(trigger).toHaveAccessibleName("Session access: Workspace; team context: No team"); + await user.keyboard("{Escape}"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + }); + + it("restores the last composer team and audience after remounting", async () => { + const user = userEvent.setup(); + mocks.teams = [team(), team({ id: "team-2", slug: "design", name: "Design" })]; + const view = render(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Design"); + await user.keyboard("{Escape}"); + await selectAudience(user, "Private"); + view.unmount(); + + render(); + expect( + await screen.findByRole("button", { + name: "Session access: Private; team context: Design", + }) + ).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-2", visibility: "private" }) + ); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + }); + + it("does not restore another user's composer selection", async () => { + const user = userEvent.setup(); + mocks.teams = [team({ defaultVisibility: "team" })]; + const view = render(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Engineering"); + await user.keyboard("{Escape}"); + await selectAudience(user, "Workspace"); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: Engineering" + ); + + mocks.userId = "user-2"; + view.rerender(); + expect( + await screen.findByRole("button", { + name: "Session access: Workspace; team context: No team", + }) + ).toBeInTheDocument(); + expect(localStorage.getItem("open-inspect-last-session-access:user-2")).toBeNull(); + }); + + it("clears a stored composer selection from a different sidebar context", async () => { + mocks.teams = [team(), team({ id: "team-2", slug: "design", name: "Design" })]; + mocks.activeTeamId = "team-1"; + localStorage.setItem( + "open-inspect-last-session-access:user-1", + JSON.stringify({ contextKey: "all-my-teams", teamId: "team-2", visibility: "private" }) + ); + render(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Engineering team; team context: Engineering" + ); + await waitFor(() => + expect(localStorage.getItem("open-inspect-last-session-access:user-1")).toBeNull() + ); + }); + + it("ignores a malformed stored composer selection", () => { + mocks.teams = [team()]; + localStorage.setItem("open-inspect-last-session-access:user-1", "{invalid"); + render(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + }); + + it("preserves Private access when the composer team context changes and warms the final pair", async () => { + const user = userEvent.setup(); + mocks.teams = [team(), team({ id: "team-2", name: "Design", defaultVisibility: "workspace" })]; + mocks.activeTeamId = "team-1"; + render(); + await selectAudience(user, "Private"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "private" }) + ); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Design"); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect(mocks.activeTeamId).toBe("team-1"); + expect( + screen.getByRole("button", { name: "Session access: Private; team context: Design" }) + ).toHaveTextContent("Private"); + expect(screen.getByRole("radio", { name: "Private" })).toBeChecked(); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: "team-2", + visibility: "private", + }); + }); + expect( + vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url) === "/api/sessions/session-1/archive") + ).toHaveLength(1); + }); + + it("selects visibility with the keyboard and restores focus to its trigger", async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole("button", { name: /^Session access:/ }); + trigger.focus(); + expect(trigger).toHaveFocus(); + await user.keyboard("[Space]"); + await waitFor(() => expect(screen.getByRole("radio", { name: "Workspace" })).toHaveFocus()); + await user.keyboard("{ArrowUp}"); + expect(screen.getByRole("radio", { name: "Private" })).toHaveFocus(); + expect(screen.getByRole("radio", { name: "Workspace" })).toBeChecked(); + await user.keyboard("{Enter}"); + expect(trigger).toHaveTextContent("Private"); + expect(screen.queryByRole("dialog", { name: "Session access" })).not.toBeInTheDocument(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it("waits for membership and settings reconciliation before warming or sending", async () => { + const user = userEvent.setup(); + mocks.teamsLoading = true; + mocks.teams = [team()]; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + mocks.teamsLoading = false; + mocks.requireTeamOnCreate = true; + mocks.teams = [team()]; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + expect(screen.getByRole("combobox", { name: "Team context" })).not.toBeDisabled(); + for (const radio of screen.getAllByRole("radio")) { + expect(radio).not.toBeDisabled(); + } + await user.click(screen.getByRole("combobox", { name: "Team context" })); + expect(screen.queryByRole("option", { name: "No team" })).not.toBeInTheDocument(); + await user.keyboard("{Escape}{Escape}"); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + }); + + it("blocks warming and shortcut submission with an inline notice when a team is required but none are available", async () => { + const user = userEvent.setup(); + mocks.requireTeamOnCreate = true; + render(); + expect(screen.getByText("Join a team to create a session.")).toBeInTheDocument(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not create workspace drafts when the team context failed to load", async () => { + const user = userEvent.setup(); + mocks.teamsError = new Error("Settings unavailable"); + render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("surfaces terminal creation codes on first typing and does not retry the denied draft", async () => { + const user = userEvent.setup(); + vi.mocked(fetch).mockResolvedValue( + Response.json({ error: "Team archived", code: "team_archived" }, { status: 409 }) + ); + render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await screen.findByText("Team archived (team_archived)"); + await user.clear(input); + await user.type(input, "Try again"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + }); + + it("can correct visibility after a terminal denial and recreate the draft", async () => { + const user = userEvent.setup(); + vi.mocked(fetch) + .mockResolvedValueOnce( + Response.json({ error: "Visibility denied", code: "visibility_denied" }, { status: 403 }) + ) + .mockResolvedValueOnce(Response.json({ sessionId: "session-1", status: "created" })); + render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await screen.findByText("Visibility denied (visibility_denied)"); + const visibility = screen.getByRole("button", { name: /^Session access:/ }); + expect(visibility).not.toBeDisabled(); + await selectAudience(user, "Private"); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: null, + visibility: "private", + }); + }); + }); + + it("consumes deferred warming after a team's default environment becomes ready", async () => { + const user = userEvent.setup(); + mocks.teams = [team({ defaultEnvironmentId: "env-1" })]; + mocks.activeTeamId = "team-1"; + mocks.environmentsLoadingValue = true; + localStorage.setItem("open-inspect-last-selected-repo", repo.fullName); + const view = render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + expect(fetch).not.toHaveBeenCalled(); + mocks.environmentsLoadingValue = false; + mocks.environmentsValue = [environment]; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ + environmentId: "env-1", + teamId: "team-1", + visibility: "team", + }) + ); + expect(sessionCreateBody()).not.toHaveProperty("repoOwner"); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx b/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx new file mode 100644 index 0000000000..b1448b1859 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx @@ -0,0 +1,337 @@ +import { afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; +import { cleanup } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { DEFAULT_MODEL } from "@open-inspect/shared/models"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + type KeyboardShortcutPreferences, +} from "@open-inspect/shared/types/keyboard-shortcuts"; +import type { TeamResponse } from "@/hooks/use-teams"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; + +expect.extend(matchers); + +const mocks = vi.hoisted(() => { + const teamContext: { + activeTeamId: string | null; + scope: "workspace" | "all" | undefined; + teams: (TeamResponse & { role: TeamRole })[]; + teamsLoading: boolean; + teamsError: unknown; + requireTeamOnCreate: boolean; + } = { + activeTeamId: null, + scope: undefined, + teams: [], + teamsLoading: false, + teamsError: undefined, + requireTeamOnCreate: false, + }; + return { + userId: "user-1", + routerPush: vi.fn(), + toastError: vi.fn(), + mutateMock: vi.fn(), + reposValue: [] as Array<{ + id: number; + fullName: string; + owner: string; + name: string; + description: string | null; + private: boolean; + defaultBranch: string; + }>, + loadingReposValue: false, + environmentsLoadingValue: false, + environmentsValue: [] as Array<{ + id: string; + name: string; + description: string | null; + prebuildEnabled: boolean; + createdAt: number; + updatedAt: number; + repositories: Array<{ + repoOwner: string; + repoName: string; + repoId: number | null; + baseBranch: string; + }>; + }>, + enabledModelsValue: [] as string[], + enabledModelsLoadingValue: false, + enabledModelOptionsValue: [] as Array<{ + category: string; + models: Array<{ id: string; name: string; description: string }>; + }>, + providerAccountsValue: [] as Array<{ + id: string; + provider: "openai" | "xai" | "anthropic"; + displayName: string; + externalAccountId: string | null; + status: "active"; + createdBy: null; + updatedBy: null; + lastVerifiedAt: null; + lastUsedAt: null; + createdAt: number; + updatedAt: number; + archivedAt: null; + }>, + providerAccountsLoadingValue: false, + skillPreview: { + skills: [ + { + skillId: "skill-1", + revisionId: "revision-1", + name: "review-pr", + description: "Review a pull request", + revisionNumber: 1, + revisionSha256: "abc", + totalBytes: 10, + assignmentSources: [], + }, + ], + totalBytes: 10, + ignoredProfileSkillIds: [], + }, + skillResolutionPreview: vi.fn(), + keyboardShortcuts: null as unknown as KeyboardShortcutPreferences, + canCreateSession: true, + ...teamContext, + setActiveTeam: vi.fn(), + }; +}); + +export { mocks }; + +export const repo = { + id: 1, + fullName: "open-inspect/background-agents", + owner: "open-inspect", + name: "background-agents", + description: null, + private: true, + defaultBranch: "main", +}; + +export const environment = { + id: "env-1", + name: "full-stack", + description: null, + prebuildEnabled: false, + createdAt: 1, + updatedAt: 1, + repositories: [{ repoOwner: "acme", repoName: "backend", repoId: 1, baseBranch: "main" }], +}; + +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: { id: mocks.userId } }, status: "authenticated" }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + +vi.mock("@/hooks/use-active-team", () => ({ + useActiveTeam: () => ({ + activeTeamId: mocks.activeTeamId, + setActiveTeam: mocks.setActiveTeam, + teams: mocks.teams, + scope: mocks.scope, + requireTeamOnCreate: mocks.requireTeamOnCreate, + loading: mocks.teamsLoading, + error: mocks.teamsError, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mocks.routerPush }), +})); +vi.mock("sonner", () => ({ toast: { error: mocks.toastError } })); + +vi.mock("swr", () => ({ + // Home uses the default export only for the picker's prebuild-status text. + default: () => ({ data: undefined, isLoading: false }), + mutate: mocks.mutateMock, +})); + +vi.mock("@/hooks/use-environments", () => ({ + ENVIRONMENTS_KEY: "/api/environments", + useEnvironments: () => ({ + environments: mocks.environmentsValue, + loading: mocks.environmentsLoadingValue, + }), +})); + +vi.mock("@/components/sidebar-layout", () => ({ + useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), +})); + +vi.mock("@/components/model-reasoning-selector", () => ({ + ModelReasoningSelector: ({ + disabled, + harness, + onHarnessChange, + onModelChange, + }: { + disabled?: boolean; + harness?: string | null; + onHarnessChange?: (harness: "opencode" | "claude") => void; + onModelChange: (model: string) => void; + }) => ( + <> + + + + {onHarnessChange && ( + <> + + + + )} + + ), +})); + +vi.mock("@/hooks/use-repos", () => ({ + useRepos: () => ({ repos: mocks.reposValue, loading: mocks.loadingReposValue }), +})); + +vi.mock("@/hooks/use-branches", () => ({ + useBranches: () => ({ branches: [{ name: "main" }], loading: false }), +})); + +vi.mock("@/hooks/use-enabled-models", () => ({ + useEnabledModels: () => ({ + enabledModels: mocks.enabledModelsValue, + enabledModelOptions: mocks.enabledModelOptionsValue, + loading: mocks.enabledModelsLoadingValue, + }), +})); + +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ + useKeyboardShortcuts: () => ({ + shortcuts: mocks.keyboardShortcuts, + labels: { + "send-prompt": + mocks.keyboardShortcuts["send-prompt"].code === "KeyJ" ? "Alt+J" : "Cmd/Ctrl+Enter", + "open-command-menu": "Cmd/Ctrl+K", + "new-session": "Cmd/Ctrl+Shift+O", + "toggle-sidebar": "Cmd/Ctrl+/", + }, + }), +})); + +vi.mock("@/hooks/use-provider-accounts", () => ({ + useProviderAccounts: () => ({ + providers: [], + accounts: mocks.providerAccountsValue, + defaults: [], + loading: mocks.providerAccountsLoadingValue, + error: undefined, + refresh: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-managed-skills", () => ({ + useSkillProfiles: () => ({ profiles: [], loading: false }), + useSkillResolutionPreview: mocks.skillResolutionPreview, +})); + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +beforeEach(() => { + mocks.reposValue = [repo]; + mocks.loadingReposValue = false; + mocks.environmentsLoadingValue = false; + mocks.environmentsValue = []; + mocks.enabledModelsValue = [DEFAULT_MODEL]; + mocks.enabledModelsLoadingValue = false; + mocks.enabledModelOptionsValue = [ + { + category: "Anthropic", + models: [{ id: DEFAULT_MODEL, name: "Claude Sonnet 4.6", description: "" }], + }, + ]; + mocks.providerAccountsValue = []; + mocks.providerAccountsLoadingValue = false; + mocks.keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS; + mocks.canCreateSession = true; + mocks.userId = "user-1"; + mocks.activeTeamId = null; + mocks.scope = undefined; + mocks.teams = []; + mocks.teamsLoading = false; + mocks.teamsError = undefined; + mocks.requireTeamOnCreate = false; + mocks.setActiveTeam.mockReset(); + mocks.routerPush.mockReset(); + mocks.toastError.mockReset(); + mocks.mutateMock.mockReset(); + mocks.skillResolutionPreview.mockReset().mockImplementation(() => ({ + preview: mocks.skillPreview, + loading: false, + error: undefined, + suggestions: { status: "ready", skills: mocks.skillPreview.skills }, + })); + // Radix Checkbox measures itself via ResizeObserver, which jsdom lacks. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/sessions") { + return Response.json({ sessionId: "session-1", status: "created" }); + } + if (url === "/api/sessions/session-1/prompt") { + return Response.json({ ok: true }); + } + if (url === "/api/sessions/session-1/archive") { + return Response.json({ ok: true }); + } + return Response.json({ error: "unexpected request" }, { status: 500 }); + }) + ); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +export function sessionCreateBody(): Record { + const calls = vi.mocked(globalThis.fetch).mock.calls; + const createCall = calls.find(([input]) => String(input) === "/api/sessions"); + expect(createCall).toBeDefined(); + return JSON.parse(String(createCall?.[1]?.body)) as Record; +} diff --git a/packages/web/src/app/(app)/(sidebar)/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/page.test.tsx index 7306ba44c6..6fe17be294 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.test.tsx @@ -1,265 +1,16 @@ // @vitest-environment jsdom /// -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import * as matchers from "@testing-library/jest-dom/matchers"; import { DEFAULT_MODEL } from "@open-inspect/shared/models"; -import { - DEFAULT_KEYBOARD_SHORTCUTS, - type KeyboardShortcutPreferences, -} from "@open-inspect/shared/types/keyboard-shortcuts"; -import Home from "./page"; +import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts"; import { isSessionInboxKey } from "@/lib/session-inbox-api"; +import { readStoredPromptDraft, writeStoredPromptDraft } from "@/lib/prompt-drafts"; import { isUnarchivedSessionListKey } from "@/lib/session-list"; - -expect.extend(matchers); - -const mocks = vi.hoisted(() => ({ - routerPush: vi.fn(), - mutateMock: vi.fn(), - reposValue: [] as Array<{ - id: number; - fullName: string; - owner: string; - name: string; - description: string | null; - private: boolean; - defaultBranch: string; - }>, - loadingReposValue: false, - environmentsLoadingValue: false, - environmentsValue: [] as Array<{ - id: string; - name: string; - description: string | null; - prebuildEnabled: boolean; - createdAt: number; - updatedAt: number; - repositories: Array<{ - repoOwner: string; - repoName: string; - repoId: number | null; - baseBranch: string; - }>; - }>, - enabledModelsValue: [] as string[], - enabledModelOptionsValue: [] as Array<{ - category: string; - models: Array<{ id: string; name: string; description: string }>; - }>, - providerAccountsValue: [] as Array<{ - id: string; - provider: "openai" | "xai" | "anthropic"; - displayName: string; - externalAccountId: string | null; - status: "active"; - createdBy: null; - updatedBy: null; - lastVerifiedAt: null; - lastUsedAt: null; - createdAt: number; - updatedAt: number; - archivedAt: null; - }>, - providerAccountsLoadingValue: false, - skillPreview: { - skills: [ - { - skillId: "skill-1", - revisionId: "revision-1", - name: "review-pr", - description: "Review a pull request", - revisionNumber: 1, - revisionSha256: "abc", - totalBytes: 10, - assignmentSources: [], - }, - ], - totalBytes: 10, - ignoredProfileSkillIds: [], - }, - keyboardShortcuts: null as unknown as KeyboardShortcutPreferences, - canCreateSession: true, -})); - -const repo = { - id: 1, - fullName: "open-inspect/background-agents", - owner: "open-inspect", - name: "background-agents", - description: null, - private: true, - defaultBranch: "main", -}; - -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }), -})); - -vi.mock("@/hooks/use-current-user-authorization", () => ({ - useCurrentUserAuthorization: () => ({ - hasPermission: (permission: string) => - permission === "sessions.create" && mocks.canCreateSession, - }), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mocks.routerPush }), -})); - -vi.mock("swr", () => ({ - // Home uses the default export only for the picker's prebuild-status text. - default: () => ({ data: undefined, isLoading: false }), - mutate: mocks.mutateMock, -})); - -vi.mock("@/hooks/use-environments", () => ({ - ENVIRONMENTS_KEY: "/api/environments", - useEnvironments: () => ({ - environments: mocks.environmentsValue, - loading: mocks.environmentsLoadingValue, - }), -})); - -vi.mock("@/components/sidebar-layout", () => ({ - useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), -})); - -vi.mock("@/components/model-reasoning-selector", () => ({ - ModelReasoningSelector: ({ - disabled, - harness, - onHarnessChange, - }: { - disabled?: boolean; - harness?: string | null; - onHarnessChange?: (harness: "opencode" | "claude") => void; - }) => ( - <> - - {onHarnessChange && ( - <> - - - - )} - - ), -})); - -vi.mock("@/hooks/use-repos", () => ({ - useRepos: () => ({ repos: mocks.reposValue, loading: mocks.loadingReposValue }), -})); - -vi.mock("@/hooks/use-branches", () => ({ - useBranches: () => ({ branches: [{ name: "main" }], loading: false }), -})); - -vi.mock("@/hooks/use-enabled-models", () => ({ - useEnabledModels: () => ({ - enabledModels: mocks.enabledModelsValue, - enabledModelOptions: mocks.enabledModelOptionsValue, - loading: false, - }), -})); - -vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ - useKeyboardShortcuts: () => ({ - shortcuts: mocks.keyboardShortcuts, - labels: { - "send-prompt": - mocks.keyboardShortcuts["send-prompt"].code === "KeyJ" ? "Alt+J" : "Cmd/Ctrl+Enter", - "open-command-menu": "Cmd/Ctrl+K", - "new-session": "Cmd/Ctrl+Shift+O", - "toggle-sidebar": "Cmd/Ctrl+/", - }, - }), -})); - -vi.mock("@/hooks/use-provider-accounts", () => ({ - useProviderAccounts: () => ({ - providers: [], - accounts: mocks.providerAccountsValue, - defaults: [], - loading: mocks.providerAccountsLoadingValue, - error: undefined, - refresh: vi.fn(), - }), -})); - -vi.mock("@/hooks/use-managed-skills", () => ({ - useSkillProfiles: () => ({ profiles: [], loading: false }), - useSkillResolutionPreview: () => ({ - preview: mocks.skillPreview, - loading: false, - error: undefined, - suggestions: { status: "ready", skills: mocks.skillPreview.skills }, - }), -})); - -beforeAll(() => { - Element.prototype.scrollIntoView = vi.fn(); -}); - -beforeEach(() => { - mocks.reposValue = [repo]; - mocks.loadingReposValue = false; - mocks.environmentsLoadingValue = false; - mocks.environmentsValue = []; - mocks.enabledModelsValue = [DEFAULT_MODEL]; - mocks.enabledModelOptionsValue = [ - { - category: "Anthropic", - models: [{ id: DEFAULT_MODEL, name: "Claude Sonnet 4.6", description: "" }], - }, - ]; - mocks.providerAccountsValue = []; - mocks.providerAccountsLoadingValue = false; - mocks.keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS; - mocks.canCreateSession = true; - mocks.routerPush.mockReset(); - mocks.mutateMock.mockReset(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/sessions") { - return Response.json({ sessionId: "session-1", status: "created" }); - } - if (url === "/api/sessions/session-1/prompt") { - return Response.json({ ok: true }); - } - return Response.json({ error: "unexpected request" }, { status: 500 }); - }) - ); -}); - -afterEach(() => { - cleanup(); - localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function sessionCreateBody(): Record { - const calls = vi.mocked(fetch).mock.calls; - const createCall = calls.find(([input]) => String(input) === "/api/sessions"); - expect(createCall).toBeDefined(); - return JSON.parse(String(createCall?.[1]?.body)) as Record; -} +import { environment, mocks, repo, sessionCreateBody } from "./page.test-fixture"; +import Home from "./page"; function activeAnthropicAccount(id: string): (typeof mocks.providerAccountsValue)[number] { return { ...activeOpenAiAccount(id), provider: "anthropic", displayName: "Owner Claude" }; @@ -283,6 +34,45 @@ function activeOpenAiAccount(id: string): (typeof mocks.providerAccountsValue)[n } describe("Home", () => { + it("creates sessions using the saved personal-memory preference without a composer override", async () => { + render(); + expect( + screen.queryByRole("checkbox", { name: "Include my personal memories" }) + ).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Do some work" }, + }); + await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/sessions", expect.anything())); + expect(sessionCreateBody()).not.toHaveProperty("includePersonalMemories"); + fireEvent.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + }); + + it("shows the first prompt's server denial reason in a toast without navigating", async () => { + vi.mocked(fetch).mockImplementation(async (input) => + String(input).endsWith("/prompt") + ? Response.json( + { + error: "Forbidden", + code: "session_action_denied", + reason_code: "missing_permission", + }, + { status: 403 } + ) + : Response.json({ sessionId: "session-1", status: "created" }) + ); + render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/sessions", expect.anything())); + fireEvent.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith("Failed to send prompt (missing_permission)") + ); + expect(mocks.routerPush).not.toHaveBeenCalled(); + }); + it("does not render session creation UI without session creation permission", () => { mocks.canCreateSession = false; @@ -526,16 +316,6 @@ describe("Home", () => { expect(body).not.toHaveProperty("branch"); }); - const environment = { - id: "env-1", - name: "full-stack", - description: null, - prebuildEnabled: false, - createdAt: 1, - updatedAt: 1, - repositories: [{ repoOwner: "acme", repoName: "backend", repoId: 1, baseBranch: "main" }], - }; - it("persists an environment selection and restores it on the next visit", async () => { mocks.environmentsValue = [environment]; const user = userEvent.setup(); @@ -624,30 +404,6 @@ describe("Home", () => { expect(localStorage.getItem("open-inspect-last-provider-selections")).toBeNull(); }); - it("continues hydrating when legacy provider selection migration fails", async () => { - const accountId = "a".repeat(32); - mocks.providerAccountsValue = [activeOpenAiAccount(accountId)]; - const selection = JSON.stringify({ openai: { mode: "provider_account", accountId } }); - localStorage.setItem("open-inspect-last-provider-selections", selection); - const setItem = localStorage.setItem.bind(localStorage); - vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, value) => { - if (key === "open-inspect-last-provider-selections:v1") throw new Error("Quota exceeded"); - setItem(key, value); - }); - const user = userEvent.setup(); - render(); - - await user.type(screen.getByPlaceholderText("What do you want to build?"), "Continue work"); - await user.click(screen.getByRole("button", { name: /send/i })); - - await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); - expect(sessionCreateBody()).toMatchObject({ - providerSelections: { openai: { mode: "provider_account", accountId } }, - }); - expect(localStorage.getItem("open-inspect-last-provider-selections:v1")).toBeNull(); - expect(localStorage.getItem("open-inspect-last-provider-selections")).toBe(selection); - }); - it("waits for provider accounts and removes a stale stored selection", async () => { const staleAccountId = "b".repeat(32); localStorage.setItem( @@ -739,6 +495,26 @@ describe("Home", () => { await screen.findByRole("button", { name: /docs/i }); }); + it("requests the skill preview for the stored target before showing its count", async () => { + localStorage.setItem("open-inspect-last-selected-repo", "open-inspect/background-agents"); + mocks.reposValue = []; + mocks.loadingReposValue = true; + const { rerender } = render(); + + expect(mocks.skillResolutionPreview).toHaveBeenLastCalledWith( + { repoOwner: "open-inspect", repoName: "background-agents" }, + { mode: "all" } + ); + expect(screen.getByRole("button", { name: /all skills/i })).not.toHaveTextContent("(1)"); + + mocks.reposValue = [repo]; + mocks.loadingReposValue = false; + rerender(); + await waitFor(() => + expect(screen.getByRole("button", { name: /all skills/i })).toHaveTextContent("(1)") + ); + }); + it("surfaces the error and stays put when the initial prompt fails", async () => { vi.mocked(fetch).mockImplementation(async (input) => { const url = String(input); @@ -762,6 +538,165 @@ describe("Home", () => { expect(await screen.findByText("Prompt rejected")).toBeInTheDocument(); expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(readStoredPromptDraft("open-inspect-prompt-draft:user-1:new-session")?.prompt).toBe( + "Investigate logs" + ); + }); + + it("restores an unsent prompt draft after a reload", async () => { + const { unmount } = render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "A long prompt that should survive a refresh" }, + }); + unmount(); + + render(); + + expect(await screen.findByPlaceholderText("What do you want to build?")).toHaveValue( + "A long prompt that should survive a refresh" + ); + }); + + it("clears the stored prompt draft once the session starts", async () => { + render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + expect(readStoredPromptDraft("open-inspect-prompt-draft:user-1:new-session")?.prompt).toBe( + "Ship it" + ); + await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/sessions", expect.anything())); + + fireEvent.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + expect(sessionStorage.getItem("open-inspect-prompt-draft:user-1:new-session")).toBeNull(); + }); + + it("sends once and preserves a newer stored draft when delivery finishes", async () => { + let resolvePrompt!: (response: Response) => void; + const originalFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation((input, init) => + String(input).endsWith("/prompt") + ? new Promise((resolve) => { + resolvePrompt = resolve; + }) + : originalFetch(input, init) + ); + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + fireEvent.change(input, { target: { value: " Ship it " } }); + await waitFor(() => expect(sessionCreateBody()).toBeDefined()); + const form = input.closest("form")!; + act(() => { + fireEvent.submit(form); + fireEvent.submit(form); + }); + await waitFor(() => expect(resolvePrompt).toBeDefined()); + writeStoredPromptDraft("open-inspect-prompt-draft:user-1:new-session", { + prompt: "A newer draft", + pendingRequest: null, + }); + let draftAtNavigation: string | undefined; + mocks.routerPush.mockImplementation(() => { + draftAtNavigation = readStoredPromptDraft( + "open-inspect-prompt-draft:user-1:new-session" + )?.prompt; + }); + await act(async () => resolvePrompt(Response.json({ ok: true }))); + expect(mocks.routerPush).toHaveBeenCalledExactlyOnceWith("/session/session-1"); + expect(draftAtNavigation).toBe("A newer draft"); + expect(mocks.mutateMock.mock.calls).toEqual([ + [isUnarchivedSessionListKey], + [isSessionInboxKey], + ]); + expect(mocks.mutateMock.mock.invocationCallOrder[1]).toBeLessThan( + mocks.routerPush.mock.invocationCallOrder[0] + ); + const sent = vi.mocked(fetch).mock.calls.filter(([url]) => String(url).endsWith("/prompt")); + expect(sent).toHaveLength(1); + expect(JSON.parse(String(sent[0][1]?.body))).toMatchObject({ content: "Ship it" }); + view.unmount(); + expect(fetch).not.toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()); + }); + + it("does not send to a session whose acquisition was superseded during submission", async () => { + let resolveCreate!: (response: Response) => void; + vi.mocked(fetch).mockImplementation((input) => + String(input) === "/api/sessions" + ? new Promise((resolve) => { + resolveCreate = resolve; + }) + : Promise.resolve(Response.json({ ok: true })) + ); + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + fireEvent.change(input, { target: { value: "Ship it" } }); + await waitFor(() => expect(resolveCreate).toBeDefined()); + fireEvent.submit(input.closest("form")!); + // Losing creation permission makes the warm request unusable, even while sending. + mocks.canCreateSession = false; + view.rerender(); + await act(async () => resolveCreate(Response.json({ sessionId: "stale", status: "created" }))); + expect(fetch).toHaveBeenCalledWith("/api/sessions/stale/archive", expect.anything()); + expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith("/prompt"))).toBe( + false + ); + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(readStoredPromptDraft("open-inspect-prompt-draft:user-1:new-session")?.prompt).toBe( + "Ship it" + ); + }); + + it("retries an attachment-only prompt without recreating or reuploading, then clears before navigation", async () => { + const revoke = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = () => "blob:preview"; + static revokeObjectURL = revoke; + } + ); + const originalFetch = vi.mocked(fetch).getMockImplementation()!; + let rejectPrompt = true; + vi.mocked(fetch).mockImplementation((input, init) => { + if (String(input).endsWith("/attachments")) { + return Promise.resolve( + Response.json({ attachmentId: "a".repeat(32), mimeType: "image/png" }) + ); + } + if (String(input).endsWith("/prompt") && rejectPrompt) { + rejectPrompt = false; + return Promise.resolve(Response.json({ error: "Please retry" }, { status: 500 })); + } + return originalFetch(input, init); + }); + const view = render(); + fireEvent.change(view.container.querySelector('input[type="file"]')!, { + target: { files: [new File(["image"], "design.png", { type: "image/png" })] }, + }); + fireEvent.click(screen.getByRole("button", { name: /send/i })); + await screen.findByText("Please retry"); + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(revoke).not.toHaveBeenCalled(); + expect(screen.getByRole("img", { name: "design.png" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + expect(revoke).toHaveBeenCalledWith("blob:preview"); + expect(revoke.mock.invocationCallOrder[0]).toBeLessThan( + mocks.routerPush.mock.invocationCallOrder[0] + ); + const sent = vi.mocked(fetch).mock.calls.find(([url]) => String(url).endsWith("/prompt")); + expect(JSON.parse(String(sent?.[1]?.body))).toMatchObject({ + content: "See the attached files.", + attachments: [{ attachmentId: "a".repeat(32), name: "design.png" }], + }); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url).endsWith("/attachments")) + ).toHaveLength(1); }); it("sends the default harness with a model it can run", async () => { @@ -782,6 +717,25 @@ describe("Home", () => { ); }); + it("uses the Codex default effort when changing the model", async () => { + mocks.enabledModelsValue = ["openai/gpt-5.6-sol", "openai/gpt-6.1-sol"]; + localStorage.setItem("open-inspect-last-selected-harness", "codex"); + localStorage.setItem("open-inspect-last-selected-model", "openai/gpt-5.6-sol"); + render(); + await screen.findByRole("button", { name: "Agent, model and effort: codex" }); + fireEvent.click(screen.getByRole("button", { name: "Switch model to GPT-6.1" })); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ + harness: "codex", + model: "openai/gpt-6.1-sol", + reasoningEffort: "low", + }) + ); + }); + it("restores a stored harness and switches an incompatible model to one it can run", async () => { const openAiModel = "openai/gpt-5.4"; mocks.enabledModelsValue = [DEFAULT_MODEL, openAiModel]; diff --git a/packages/web/src/app/(app)/(sidebar)/page.tsx b/packages/web/src/app/(app)/(sidebar)/page.tsx index fe6f3de02c..4255b1dd5e 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.tsx @@ -1,47 +1,41 @@ "use client"; import { useAuthSession } from "@/lib/auth-session"; -import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { mutate } from "swr"; -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { ErrorBanner } from "@/components/ui/error-banner"; +import { SessionAccessSelector } from "@/components/session-access-selector"; import { matchesShortcut } from "@/lib/keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { isUnarchivedSessionListKey } from "@/lib/session-list"; import { isSessionInboxKey } from "@/lib/session-inbox-api"; import { APP_NAME } from "@/lib/site-config"; -import type { SessionAttachmentReference } from "@open-inspect/shared/types/session-attachments"; import { MAX_WEB_PROMPT_CHARS } from "@open-inspect/shared/types/websocket"; import { - DEFAULT_MODEL, - getDefaultReasoningEffort, getSubscriptionProviderForModel, type ModelCategory, type ReasoningEffort, type ValidModel, } from "@open-inspect/shared/models"; -import type { ModelPreference } from "@/lib/model-selection"; -import { - DEFAULT_HARNESS, - getValidHarnessOrDefault, - reconcileProviderSelectionsForHarness, - type HarnessId, -} from "@open-inspect/shared/harnesses"; -import { resolveHarnessModelSelection } from "@/lib/session-harness"; +import type { HarnessId } from "@open-inspect/shared/harnesses"; +import { useLaunchPreferences } from "@/hooks/use-launch-preferences"; +import { useFirstPromptLaunch } from "@/hooks/use-first-prompt-launch"; +import { firstPromptDelivery } from "@/lib/first-prompt-delivery"; import { useEnabledModels } from "@/hooks/use-enabled-models"; import { useAttachmentDropZone } from "@/hooks/use-attachment-drop-zone"; import { ATTACHMENT_ACCEPT, - DEFAULT_ATTACHMENT_ONLY_MESSAGE, useSessionAttachments, + type PendingAttachment, } from "@/hooks/use-session-attachments"; import { AttachmentPreviewStrip } from "@/components/attachment-preview-strip"; import { useSessionTargetPicker, - type SessionTargetSelection, + type SessionTargetPickerProps, } from "@/hooks/use-session-target-picker"; import { SessionTargetPicker } from "@/components/session-target-picker"; import { ModelReasoningSelector } from "@/components/model-reasoning-selector"; @@ -58,6 +52,8 @@ import type { SessionTargetRequestFields } from "@/lib/session-target"; import type { PromptSkillSuggestionSource } from "@/lib/prompt-skill-completion"; import type { ModelProviderSelections, + ModelProviderAccount, + ModelProviderAccountDefault, ProviderAuthSelection, SubscriptionProviderId, } from "@open-inspect/shared/types/provider-accounts"; @@ -65,18 +61,26 @@ import { ProviderAuthControls } from "@/components/provider-auth-controls"; import { useProviderAccounts } from "@/hooks/use-provider-accounts"; import { useWarmDraftSession, type WarmDraftSessionRequest } from "@/hooks/use-warm-draft-session"; import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useActiveTeam } from "@/hooks/use-active-team"; +import { usePromptDraft } from "@/hooks/use-prompt-draft"; +import { NEW_SESSION_PROMPT_DRAFT_ID } from "@/lib/prompt-drafts"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; import { - buildInteractiveProviderRoutingIdentity, - parseStoredProviderSelections, - reconcileProviderSelections, - setProviderSelection, -} from "@/lib/provider-selection"; - -const LAST_SELECTED_MODEL_STORAGE_KEY = "open-inspect-last-selected-model"; -const LAST_SELECTED_HARNESS_STORAGE_KEY = "open-inspect-last-selected-harness"; -const LAST_SELECTED_REASONING_EFFORT_STORAGE_KEY = "open-inspect-last-selected-reasoning-effort"; -const LEGACY_PROVIDER_SELECTIONS_STORAGE_KEY = "open-inspect-last-provider-selections"; -const LAST_PROVIDER_SELECTIONS_STORAGE_KEY = "open-inspect-last-provider-selections:v1"; + parseStoredComposerAccess, + resolveComposerAccess, + type ComposerAccessDraft, +} from "@/lib/composer-access"; +import { buildInteractiveProviderRoutingIdentity } from "@/lib/provider-selection"; +import type { TeamResponse } from "@/hooks/use-teams"; + +const LAST_SESSION_ACCESS_STORAGE_KEY = "open-inspect-last-session-access"; + +// What the composer shows before the session target is confirmed. +const UNRESOLVED_SKILL_PREVIEW: { + preview: SkillResolutionPreviewResponse | null; + loading: boolean; + suggestions: PromptSkillSuggestionSource; +} = { preview: null, loading: false, suggestions: { status: "ready", skills: [] } }; function skillPreviewTarget( fields: SessionTargetRequestFields | null @@ -101,329 +105,232 @@ export default function Home() { const { hasPermission } = useCurrentUserAuthorization(); const canCreateSession = hasPermission("sessions.create"); const router = useRouter(); - const picker = useSessionTargetPicker(); - const { sessionTarget, buildRequestFields, isLaunchable } = picker; - const [storedPreference, setStoredPreference] = useState({ - model: DEFAULT_MODEL, - reasoningEffort: getDefaultReasoningEffort(DEFAULT_MODEL), + const teamContext = useActiveTeam(); + const { teams, requireTeamOnCreate, loading: loadingTeams, error: teamError } = teamContext; + const [accessDraft, setAccessDraft] = useState(null); + const { contextKey, teamId, visibility } = resolveComposerAccess(teamContext, accessDraft); + const selectedTeam = teams.find((team) => team.id === teamId); + const teamCreationReady = + !loadingTeams && !teamError && (teamId === null ? !requireTeamOnCreate : !!selectedTeam); + const picker = useSessionTargetPicker({ + teamId, + defaultEnvironmentId: selectedTeam?.defaultEnvironmentId, }); - const [modelPreferenceDraft, setModelPreferenceDraft] = useState(null); - const [harness, setHarness] = useState(DEFAULT_HARNESS); - const [prompt, setPrompt] = useState(""); - const [skillSelection, setSkillSelection] = useState({ mode: "all" }); - const [providerSelections, setProviderSelections] = useState({}); - const [providerSelectionsHydrated, setProviderSelectionsHydrated] = useState(false); - const providerAccounts = useProviderAccounts(); - const sessionAttachments = useSessionAttachments(); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(""); - const submitInFlightRef = useRef(false); - const hasHydratedModelPreferencesRef = useRef(false); - const { enabledModels, enabledModelOptions, loading: loadingEnabledModels } = useEnabledModels(); - const targetRequestFields = buildRequestFields(); - const currentSkillPreviewTarget = session ? skillPreviewTarget(targetRequestFields) : null; - const { - preview: skillPreview, - loading: skillPreviewLoading, - suggestions: skillSuggestions, - } = useSkillResolutionPreview(currentSkillPreviewTarget, skillSelection); + const { sessionTarget, buildRequestFields, isLaunchable } = picker; - useEffect(() => { - if (hasHydratedModelPreferencesRef.current) return; + const accessStorageKey = session ? `${LAST_SESSION_ACCESS_STORAGE_KEY}:${session.user.id}` : null; + const accessContextReady = !teamContext.loading && !teamContext.error; - const storedModel = localStorage.getItem(LAST_SELECTED_MODEL_STORAGE_KEY); - const storedReasoningEffort = localStorage.getItem(LAST_SELECTED_REASONING_EFFORT_STORAGE_KEY); - const storedProviderSelectionsValue = localStorage.getItem( - LAST_PROVIDER_SELECTIONS_STORAGE_KEY - ); - const legacyProviderSelectionsValue = - storedProviderSelectionsValue === null - ? localStorage.getItem(LEGACY_PROVIDER_SELECTIONS_STORAGE_KEY) + // Restore the user's last composer team/audience; it only applies while its sidebar context matches. + useEffect(() => { + let stored: ComposerAccessDraft | null = null; + try { + stored = accessStorageKey + ? parseStoredComposerAccess(localStorage.getItem(accessStorageKey)) : null; - const storedProviderSelections = parseStoredProviderSelections( - storedProviderSelectionsValue ?? legacyProviderSelectionsValue - ); - if (legacyProviderSelectionsValue !== null) { + } catch { + // Storage is optional; the composer falls back to the sidebar team defaults. + } + setAccessDraft(stored); + }, [accessStorageKey]); + + const saveAccessDraft = useCallback( + (draft: ComposerAccessDraft) => { + setAccessDraft(draft); + if (!accessStorageKey) return; try { - if (storedProviderSelections) { - localStorage.setItem( - LAST_PROVIDER_SELECTIONS_STORAGE_KEY, - JSON.stringify(storedProviderSelections) - ); - } - localStorage.removeItem(LEGACY_PROVIDER_SELECTIONS_STORAGE_KEY); + localStorage.setItem(accessStorageKey, JSON.stringify(draft)); } catch { - // Storage migration must not block provider-selection hydration. + // Continue with the in-memory selection when storage is unavailable. } - } - setStoredPreference({ - model: storedModel ?? DEFAULT_MODEL, - reasoningEffort: storedReasoningEffort ?? undefined, - }); - setHarness(getValidHarnessOrDefault(localStorage.getItem(LAST_SELECTED_HARNESS_STORAGE_KEY))); - if (storedProviderSelections) setProviderSelections(storedProviderSelections); - setProviderSelectionsHydrated(true); - hasHydratedModelPreferencesRef.current = true; - }, []); - - // Selections both the account list and the harness can honour. The effect - // below persists any change, so a pin the harness cannot use stays dropped. - const availableProviderSelections = reconcileProviderSelectionsForHarness( - harness, - providerAccounts.loading - ? providerSelections - : reconcileProviderSelections(providerSelections, providerAccounts.accounts) + }, + [accessStorageKey] ); + // Composer context changes preserve the audience; sidebar changes use team defaults. useEffect(() => { - if ( - !providerSelectionsHydrated || - providerAccounts.loading || - availableProviderSelections === providerSelections - ) { - return; - } + if (teamContext.loading || teamContext.error) return; + setAccessDraft((draft) => (draft ? resolveComposerAccess(teamContext, draft) : null)); + }, [teamContext]); - setProviderSelections(availableProviderSelections); - localStorage.setItem( - LAST_PROVIDER_SELECTIONS_STORAGE_KEY, - JSON.stringify(availableProviderSelections) - ); - }, [ - availableProviderSelections, - providerAccounts.loading, - providerSelections, - providerSelectionsHydrated, - ]); - - // The harness fixes which enabled models can be picked; a stored or drafted - // model the harness cannot run resolves to a compatible one instead. - const modelSelection = useMemo( - () => - resolveHarnessModelSelection({ - harness, - preference: modelPreferenceDraft ?? storedPreference, - enabledModels, - enabledModelOptions, - loading: loadingEnabledModels, - }), - [ - enabledModelOptions, - enabledModels, - harness, - loadingEnabledModels, - modelPreferenceDraft, - storedPreference, - ] + // Sidebar changes discard the draft, so drop the saved one too rather than reviving it on reload. + useEffect(() => { + if (!accessContextReady || !accessStorageKey) return; + try { + const stored = parseStoredComposerAccess(localStorage.getItem(accessStorageKey)); + if (stored && stored.contextKey !== contextKey) localStorage.removeItem(accessStorageKey); + } catch { + // Storage is optional. + } + }, [accessContextReady, accessStorageKey, contextKey]); + const { prompt, setPrompt, clearSubmittedPrompt } = usePromptDraft(NEW_SESSION_PROMPT_DRAFT_ID); + const [warmRequested, setWarmRequested] = useState(false); + const [skillSelection, setSkillSelection] = useState({ mode: "all" }); + const providerAccounts = useProviderAccounts(); + const sessionAttachments = useSessionAttachments(); + const models = useEnabledModels(); + const preferences = useLaunchPreferences({ models, providerAccounts }); + const { selectedModel, reasoningEffort, harness, providerSelections } = preferences; + const targetRequestFields = buildRequestFields(); + const currentSkillPreviewTarget = session ? skillPreviewTarget(targetRequestFields) : null; + // Fetch the preview for the anticipated target so it loads with the picker's catalogs, but + // show it only once the target is confirmed. + const anticipatedSkillPreview = useSkillResolutionPreview( + session ? skillPreviewTarget(picker.anticipatedRequestFields) : null, + skillSelection ); - const { model: selectedModel, reasoningEffort } = modelSelection; - const harnessHasModels = modelSelection.availability.status !== "unavailable"; + const { + preview: skillPreview, + loading: skillPreviewLoading, + suggestions: skillSuggestions, + } = currentSkillPreviewTarget ? anticipatedSkillPreview : UNRESOLVED_SKILL_PREVIEW; + const launchContextReady = canCreateSession && teamCreationReady && preferences.providerReady; const warmRequest: WarmDraftSessionRequest | null = - canCreateSession && - session && - providerSelectionsHydrated && - !providerAccounts.loading && - !loadingEnabledModels && - harnessHasModels && - targetRequestFields + launchContextReady && session && preferences.ready && targetRequestFields ? { ...targetRequestFields, harness, model: selectedModel, reasoningEffort, skillSelection, - providerSelections: availableProviderSelections, + providerSelections, + teamId, + visibility, } : null; const warmRoutingIdentity = buildInteractiveProviderRoutingIdentity( - availableProviderSelections, + providerSelections, providerAccounts.defaults, providerAccounts.accounts ); + const warmSession = useWarmDraftSession(warmRequest, warmRoutingIdentity); const { - sessionId: pendingSessionId, - isWarming: isCreatingSession, + identity: warmIdentity, warm: createSessionForWarming, - consume: consumeWarmSession, - } = useWarmDraftSession(warmRequest, warmRoutingIdentity); - - const saveModelPreferenceDraft = useCallback((preference: ModelPreference) => { - setModelPreferenceDraft(preference); - localStorage.setItem(LAST_SELECTED_MODEL_STORAGE_KEY, preference.model); - if (preference.reasoningEffort) { - localStorage.setItem(LAST_SELECTED_REASONING_EFFORT_STORAGE_KEY, preference.reasoningEffort); - } else { - localStorage.removeItem(LAST_SELECTED_REASONING_EFFORT_STORAGE_KEY); - } - }, []); + error: creationError, + } = warmSession; - const handleModelChange = useCallback( - (model: ValidModel) => { - saveModelPreferenceDraft({ model, reasoningEffort: getDefaultReasoningEffort(model) }); - }, - [saveModelPreferenceDraft] - ); - - const handleReasoningEffortChange = useCallback( - (nextReasoningEffort: ReasoningEffort | undefined) => { - saveModelPreferenceDraft({ model: selectedModel, reasoningEffort: nextReasoningEffort }); - }, - [saveModelPreferenceDraft, selectedModel] - ); - - const handleHarnessChange = useCallback((nextHarness: HarnessId) => { - setHarness(nextHarness); - localStorage.setItem(LAST_SELECTED_HARNESS_STORAGE_KEY, nextHarness); - }, []); - - const handleProviderSelectionChange = useCallback( - (provider: SubscriptionProviderId, selection: ProviderAuthSelection | undefined) => { - const next = setProviderSelection(availableProviderSelections, provider, selection); - setProviderSelections(next); - localStorage.setItem(LAST_PROVIDER_SELECTIONS_STORAGE_KEY, JSON.stringify(next)); + useEffect(() => { + if (!warmRequested || !warmIdentity) return; + setWarmRequested(false); + void createSessionForWarming(); + }, [warmRequested, warmIdentity, createSessionForWarming]); + + const hasAttachments = sessionAttachments.attachments.length > 0; + const launch = useFirstPromptLaunch( + { + prompt, + model: selectedModel, + reasoningEffort, + hasAttachments, + blocked: + !launchContextReady || + !!creationError?.terminal || + sessionAttachments.isUploading || + models.loading, + validationError: + preferences.modelAvailability.status === "unavailable" + ? preferences.modelAvailability.message + : !isLaunchable + ? sessionTarget?.kind === "repos" + ? "Select at least one repository" + : "Please select a repository or environment" + : null, }, - [availableProviderSelections] - ); - - const handlePromptChange = (value: string) => { - const wasEmpty = prompt.length === 0; - setPrompt(value); - if ( - wasEmpty && - value.length > 0 && - !pendingSessionId && - !isCreatingSession && - !loadingEnabledModels && - isLaunchable - ) { - createSessionForWarming(); - } - }; - - const handleAddFiles = (files: Iterable) => { - sessionAttachments.addFiles(files); - if (!pendingSessionId && !isCreatingSession && isLaunchable) { - createSessionForWarming(); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if ( - !canCreateSession || - submitInFlightRef.current || - sessionAttachments.isUploading || - !providerSelectionsHydrated || - providerAccounts.loading || - loadingEnabledModels - ) { - return; + { + sessions: warmSession, + attachments: sessionAttachments, + delivery: firstPromptDelivery, + completion: { + launched(sessionId, submittedPrompt) { + clearSubmittedPrompt(submittedPrompt); + sessionAttachments.clearAttachments(); + mutate(isUnarchivedSessionListKey); + mutate(isSessionInboxKey); + router.push(`/session/${sessionId}`); + }, + denied: toast.error, + }, } - const hasAttachments = sessionAttachments.attachments.length > 0; - if (!prompt.trim() && !hasAttachments) return; - if (modelSelection.availability.status === "unavailable") { - setError(modelSelection.availability.message); - return; - } - if (!isLaunchable) { - setError( - sessionTarget?.kind === "repos" - ? "Select at least one repository" - : "Please select a repository or environment" - ); - return; - } - - submitInFlightRef.current = true; - setCreating(true); - setError(""); - - try { - let sessionId = pendingSessionId; - if (!sessionId) { - sessionId = await createSessionForWarming(); - } - - if (!sessionId) { - setError("Failed to create session"); - return; - } - - let attachments: SessionAttachmentReference[] | undefined; - if (hasAttachments) { - try { - attachments = await sessionAttachments.uploadAll(sessionId); - } catch { - return; - } - } + ); - const res = await browserApiFetch(`/api/sessions/${sessionId}/prompt`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: prompt.trim() || DEFAULT_ATTACHMENT_ONLY_MESSAGE, - model: selectedModel, - reasoningEffort, - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }), - }); - - if (res.ok) { - consumeWarmSession(sessionId); - sessionAttachments.clearAttachments(); - mutate(isUnarchivedSessionListKey); - mutate(isSessionInboxKey); - router.push(`/session/${sessionId}`); - } else { - const data = await res.json(); - setError(data.error || "Failed to send prompt"); - setCreating(false); - } - } catch (_error) { - setError("Failed to create session"); - } finally { - submitInFlightRef.current = false; - setCreating(false); - } - }; + // Unlike warming, Send remains available for model/terminal feedback. Submission + // still guards loading and terminal failures, including keyboard shortcuts. + const sendDisabled = + (!prompt.trim() && !hasAttachments) || + launch.submitting || + sessionAttachments.isUploading || + !launchContextReady || + !isLaunchable; return ( { + saveAccessDraft({ contextKey, teamId, visibility }); + }} + onVisibilityChange={(value) => { + if (teamId !== null || value !== "team") + saveAccessDraft({ + contextKey, + teamId, + visibility: value, + }); + }} selectedModel={selectedModel} - setSelectedModel={handleModelChange} + setSelectedModel={preferences.handleModelChange} reasoningEffort={reasoningEffort} - setReasoningEffort={handleReasoningEffortChange} + setReasoningEffort={preferences.handleReasoningEffortChange} harness={harness} - setHarness={handleHarnessChange} + setHarness={preferences.handleHarnessChange} prompt={prompt} - handlePromptChange={handlePromptChange} + handlePromptChange={(value) => { + setPrompt(value); + setWarmRequested(value.length > 0); + }} attachments={{ items: sessionAttachments.attachments, error: sessionAttachments.attachmentError, isUploading: sessionAttachments.isUploading, - onAdd: handleAddFiles, + onAdd: sessionAttachments.addFiles, onRemove: sessionAttachments.removeAttachment, }} - creating={creating} - isCreatingSession={isCreatingSession} - providerSelectionsHydrated={providerSelectionsHydrated} - error={error} - handleSubmit={handleSubmit} - modelOptions={modelSelection.options} + launch={{ + submitting: launch.submitting, + warming: warmSession.isWarming, + sendDisabled, + error: creationError?.message ?? launch.error, + onSubmit: (event) => { + event.preventDefault(); + void launch.submit(); + }, + }} + modelOptions={preferences.modelOptions} skillSelection={skillSelection} setSkillSelection={setSkillSelection} skillPreviewTarget={currentSkillPreviewTarget} skillPreview={skillPreview} skillPreviewLoading={skillPreviewLoading} skillSuggestions={skillSuggestions} - providerSelections={availableProviderSelections} - onProviderSelectionChange={handleProviderSelectionChange} - providerAccounts={providerAccounts} + providerSelections={providerSelections} + onProviderSelectionChange={preferences.handleProviderSelectionChange} + providerAccounts={{ + accounts: providerAccounts.accounts, + defaults: providerAccounts.defaults, + }} /> ); } @@ -432,6 +339,12 @@ function HomeContent({ isAuthenticated, canCreateSession, picker, + targetKind, + access, + teamId, + visibility, + onTeamChange, + onVisibilityChange, selectedModel, setSelectedModel, reasoningEffort, @@ -441,11 +354,7 @@ function HomeContent({ prompt, handlePromptChange, attachments, - creating, - isCreatingSession, - providerSelectionsHydrated, - error, - handleSubmit, + launch, modelOptions, skillSelection, setSkillSelection, @@ -459,7 +368,19 @@ function HomeContent({ }: { isAuthenticated: boolean; canCreateSession: boolean; - picker: SessionTargetSelection; + picker: SessionTargetPickerProps; + targetKind: NonNullable["kind"] | undefined; + access: { + teams: TeamResponse[]; + requireTeamOnCreate: boolean; + loading: boolean; + failed: boolean; + ready: boolean; + }; + teamId: string | null; + visibility: SessionVisibility; + onTeamChange: (teamId: string | null) => void; + onVisibilityChange: (value: SessionVisibility) => void; selectedModel: ValidModel; setSelectedModel: (value: ValidModel) => void; reasoningEffort: ReasoningEffort | undefined; @@ -469,17 +390,19 @@ function HomeContent({ prompt: string; handlePromptChange: (value: string) => void; attachments: { - items: ReturnType["attachments"]; + items: PendingAttachment[]; error: string | null; isUploading: boolean; onAdd: (files: Iterable) => void; onRemove: (id: string) => void; }; - creating: boolean; - isCreatingSession: boolean; - providerSelectionsHydrated: boolean; - error: string; - handleSubmit: (e: React.FormEvent) => void; + launch: { + submitting: boolean; + warming: boolean; + sendDisabled: boolean; + error: string; + onSubmit: (event: React.FormEvent) => void; + }; modelOptions: ModelCategory[]; skillSelection: SessionSkillSelection; setSkillSelection: (value: SessionSkillSelection) => void; @@ -492,12 +415,18 @@ function HomeContent({ provider: SubscriptionProviderId, selection: ProviderAuthSelection | undefined ) => void; - providerAccounts: ReturnType; + providerAccounts: { accounts: ModelProviderAccount[]; defaults: ModelProviderAccountDefault[] }; }) { const { isOpen } = useSidebarContext(); const { shortcuts, labels } = useKeyboardShortcuts(); const inputRef = useRef(null); const fileInputRef = useRef(null); + const { + submitting: creating, + warming: isCreatingSession, + error, + onSubmit: handleSubmit, + } = launch; const attachmentsLocked = creating || attachments.isUploading; const { isDraggingOver, @@ -507,7 +436,7 @@ function HomeContent({ handleDragOver, handleDragLeave, } = useAttachmentDropZone({ locked: attachmentsLocked, onAdd: attachments.onAdd }); - const { sessionTarget, selectedRepo, repos, loadingRepos, isLaunchable } = picker; + const { repos, loadingRepos } = picker; const selectedProvider = getSubscriptionProviderForModel(selectedModel); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -552,9 +481,18 @@ function HomeContent({ {isAuthenticated && canCreateSession && (
{error && {error}} + {access.failed ? ( + + Unable to load team memberships and settings. + + ) : !access.loading && access.requireTeamOnCreate && access.teams.length === 0 ? ( +

+ Join a team to create a session. +

+ ) : null}
- +
- {/* Footer row with session controls */} + {/* Agent configuration stays inside the composer. */}
+ + {/* Secrets disclosure per session target (design §7.4) */} - {sessionTarget?.kind === "environment" && ( + {targetKind === "environment" && (

Sessions from this environment use global secrets plus the environment's secrets.

)} - {sessionTarget?.kind === "repos" && ( + {targetKind === "repos" && (

Ad-hoc sessions use global secrets plus the selected repositories' secrets, and don't get prebuilt images —{" "} @@ -694,17 +637,6 @@ function HomeContent({

)} - {selectedRepo && ( -
- - Manage secrets and settings - -
- )} - {repos.length === 0 && !loadingRepos && (

No repositories found. You can start without a repository or grant repository diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx new file mode 100644 index 0000000000..c7130a0f63 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx @@ -0,0 +1,348 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { Component, memo, useEffect, useRef, type PropsWithChildren, type ReactNode } from "react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; +import type { SessionDiffState } from "@open-inspect/shared/types/session-diffs"; +import { SafeMarkdown } from "@/components/safe-markdown"; +import { resolveSessionCapabilities } from "@/lib/session-capabilities"; +import SessionPage from "./page"; + +const mocks = vi.hoisted(() => ({ + socket: vi.fn(), + prompt: vi.fn(), + refreshSnapshot: vi.fn(), + actionBar: vi.fn(), + header: vi.fn(), + sidebar: vi.fn(), + overlay: vi.fn(), + composer: vi.fn(), + timeline: vi.fn(), + diffState: null as SessionDiffState | null, + snapshot: null as SessionSnapshot | null, + mobile: false, + enabledModels: [] as string[], +})); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("./session-snapshot-provider", () => ({ + useSessionSnapshot: () => mocks.snapshot, + useRefreshSessionSnapshot: () => mocks.refreshSnapshot, +})); +vi.mock("@/hooks/use-session-socket", () => ({ useSessionSocket: mocks.socket })); +vi.mock("@/hooks/use-prompt-input", () => ({ usePromptInput: mocks.prompt })); +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ + useKeyboardShortcuts: () => ({ shortcuts: {} }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ hasPermission: () => true }), +})); +vi.mock("@/hooks/use-mark-session-read", () => ({ useMarkSessionRead: vi.fn() })); +vi.mock("@/hooks/use-session-participant-profiles", () => ({ + useSessionParticipantProfiles: () => ({ profiles: new Map(), participants: [] }), +})); +vi.mock("@/hooks/use-session-skills", () => ({ + useSessionSkills: () => ({ suggestions: [] }), +})); +vi.mock("@/hooks/use-session-rename", () => ({ + useSessionRename: () => ({ optimisticTitle: null, renameSession: vi.fn() }), +})); +vi.mock("@/hooks/use-enabled-models", () => ({ + useEnabledModels: () => ({ + enabledModels: mocks.enabledModels, + enabledModelOptions: [], + loading: false, + }), +})); +vi.mock("@/hooks/use-session-diffs", () => ({ + useSessionDiffs: () => ({ state: mocks.diffState, isLoading: false }), +})); +vi.mock("@/hooks/use-media-query", () => ({ useMediaQuery: () => mocks.mobile })); +vi.mock("@/hooks/use-session-details-sidebar", () => ({ + useSessionDetailsSidebar: () => ({ isOpen: true, toggle: vi.fn() }), +})); +vi.mock("react-resizable-panels", () => ({ + Group: ({ children }: PropsWithChildren) =>

{children}
, + Panel: ({ children }: PropsWithChildren) =>
{children}
, + Separator: () => null, +})); +vi.mock("@/components/action-bar", () => ({ ActionBar: mocks.actionBar })); +vi.mock("@/components/session-header", () => ({ SessionHeader: mocks.header })); +vi.mock("@/components/session-right-sidebar", () => ({ SessionRightSidebar: mocks.sidebar })); +vi.mock("@/components/session-details-overlay", () => ({ SessionDetailsOverlay: mocks.overlay })); +vi.mock("@/components/session-prompt-composer", () => ({ SessionPromptComposer: mocks.composer })); +vi.mock("@/components/session-timeline", () => ({ SessionTimeline: mocks.timeline })); +vi.mock("@/components/media-lightbox", () => ({ MediaLightbox: () => null })); +vi.mock("@/components/queued-prompt-stack", () => ({ QueuedPromptStack: () => null })); +vi.mock("@/components/session-desktop-layout", () => ({ + SessionDesktopLayout: ({ + workspace, + sidebar, + changes, + }: { + workspace: ReactNode; + sidebar: ReactNode; + changes: ReactNode; + }) => ( + <> + + {changes} + {sidebar} + + ), +})); +// Like the real panel, it takes focus when it opens. +vi.mock("@/components/session-changes-panel", () => ({ + SessionChangesPanel: function ChangesPanel({ onClose }: { onClose: () => void }) { + const ref = useRef(null); + useEffect(() => ref.current?.focus(), []); + return ( +
+ +
+ ); + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.mobile = false; + mocks.enabledModels = []; + mocks.diffState = null; + mocks.snapshot = { + session: { + id: "session-1", + title: "Cached secret", + harness: "opencode", + repoOwner: null, + repoName: null, + baseBranch: null, + branchName: null, + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + ownerTeamId: "team_design", + ownerUserId: "user_owner", + visibility: "private", + collaborators: ["user_collaborator"], + capabilities: { + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canManageCollaborators: true, + canChangeVisibility: true, + canSandbox: false, + }, + }, + artifacts: [], + timeline: { events: [], hasMore: false, cursor: null }, + promptQueue: [], + }; + mocks.socket.mockReturnValue({ + sessionGone: false, + sessionState: mocks.snapshot.session, + capabilities: resolveSessionCapabilities(mocks.snapshot.session.capabilities, true), + events: [], + participants: [], + artifacts: [], + promptQueue: [], + canManageBudget: false, + }); + mocks.prompt.mockReturnValue({ + sessionAttachments: { isUploading: false, attachments: [] }, + inputRef: { current: null }, + }); + mocks.refreshSnapshot.mockResolvedValue(undefined); + for (const component of [ + mocks.actionBar, + mocks.header, + mocks.sidebar, + mocks.overlay, + mocks.composer, + mocks.timeline, + ]) { + component.mockReturnValue(null); + } +}); + +class NotFoundBoundary extends Component { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error?.message === "NEXT_NOT_FOUND") return

404 Not Found

; + if (this.state.error) return

Generic unavailable

; + return this.props.children; + } +} + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); +}); + +it("renders the existing not-found path before cached session content or action hooks", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.socket.mockReturnValue({ sessionGone: true }); + render( + + + + ); + expect(screen.queryByText("404 Not Found")).not.toBeNull(); + expect(screen.queryByText("Cached secret")).toBeNull(); + expect(screen.queryByText("Generic unavailable")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + expect(mocks.prompt).not.toHaveBeenCalled(); +}); + +it.each([undefined, "high"])( + "uses Codex fallback without overwriting explicit effort %s", + (effort) => { + mocks.enabledModels = ["openai/gpt-5.6-sol", "openai/gpt-6.1-sol"]; + Object.assign(mocks.snapshot!.session, { + harness: "codex", + model: "openai/gpt-5.6-sol", + reasoningEffort: effort, + }); + mocks.socket.mockReturnValue({ + ...mocks.socket(), + capabilities: { ...mocks.socket().capabilities, collaborate: true }, + }); + render(); + expect(mocks.prompt.mock.lastCall?.[4]).toBe(effort ?? "low"); + act(() => mocks.composer.mock.lastCall?.[0].model.onModelChange("openai/gpt-6.1-sol")); + expect(mocks.prompt.mock.lastCall?.[3]).toBe("openai/gpt-6.1-sol"); + expect(mocks.prompt.mock.lastCall?.[4]).toBe("low"); + act(() => mocks.composer.mock.lastCall?.[0].model.onReasoningEffortChange("max")); + expect(mocks.prompt.mock.lastCall?.[4]).toBe("max"); + } +); + +it("opens phone media on Info without replacing the remembered inspector tab", async () => { + localStorage.setItem("open-inspect-session-inspector-tab", "changes"); + mocks.mobile = true; + render(); + await waitFor(() => expect(mocks.overlay.mock.lastCall?.[0].activeTab).toBe("changes")); + expect(mocks.overlay.mock.lastCall?.[0].open).toBe(false); + + act(() => mocks.header.mock.lastCall?.[0].onOpenMobileMedia()); + + expect(mocks.overlay.mock.lastCall?.[0]).toMatchObject({ open: true, activeTab: "info" }); + expect(localStorage.getItem("open-inspect-session-inspector-tab")).toBe("changes"); +}); + +it("keeps desktop actions available without collaboration and refreshes sidebar and overlay scope", () => { + const { rerender } = render(); + expect(mocks.composer).not.toHaveBeenCalled(); + expect(mocks.actionBar.mock.lastCall?.[0]).toMatchObject({ + capabilities: { collaborate: false, changeVisibility: true, manageCollaborators: true }, + }); + expect(mocks.sidebar.mock.lastCall?.[0].scope.ownerTeamId).toBe("team_design"); + + mocks.snapshot = { + ...mocks.snapshot!, + session: { + ...mocks.snapshot!.session, + visibility: "team", + collaborators: [], + }, + }; + mocks.mobile = true; + rerender(); + for (const scope of [ + mocks.sidebar.mock.lastCall?.[0].scope, + mocks.overlay.mock.lastCall?.[0].scope, + ]) { + expect(scope).toMatchObject({ + ownerTeamId: "team_design", + visibility: "team", + collaborators: [], + onUpdated: mocks.refreshSnapshot, + }); + } +}); + +// Memoized like the timeline's EventItem, so page re-renders keep the rendered link. +const AssistantMessage = memo(function AssistantMessage() { + return ; +}); + +it.each([ + ["the desktop changes panel", false], + ["the mobile changes sheet", true], +])("returns focus to the timeline file link after closing %s", async (_surface, mobile) => { + mocks.mobile = mobile; + mocks.diffState = { + version: 1, + current: { + version: 1, + revisionId: "revision-1", + capturedAt: 100, + triggerMessageId: null, + repositories: [ + { + status: "ready", + position: 0, + repoOwner: "acme", + repoName: "web", + baseSha: "a".repeat(40), + headSha: "b".repeat(40), + truncated: false, + omittedFileCount: 0, + files: [ + { + id: "file-1", + path: "src/app.ts", + status: "modified", + additions: 1, + deletions: 0, + renderState: "renderable", + }, + ], + }, + ], + }, + lastError: null, + unavailableReason: null, + }; + mocks.timeline.mockImplementation(() => ); + // The details sidebar lists the same file; below lg it is not on screen. + mocks.sidebar.mockImplementation(() => ( + + )); + // jsdom has no layout, so offsetParent stands in for "rendered". + vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function ( + this: HTMLElement + ) { + return this.closest("[hidden]") ? null : document.body; + }); + const user = userEvent.setup(); + render(); + + const link = screen.getByRole("button", { name: "src/app.ts" }); + await user.click(link); + await user.click(await screen.findByRole("button", { name: "Close changes" })); + + await waitFor(() => expect(link).toHaveFocus()); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index cfd0989a99..922e9e88c1 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; +import { notFound, useRouter } from "next/navigation"; import { mutate } from "swr"; import useSWRMutation from "swr/mutation"; import { useState, useRef, useEffect, useCallback, useMemo } from "react"; @@ -11,17 +11,16 @@ import { MediaLightbox } from "@/components/media-lightbox"; import { SessionHeader } from "@/components/session-header"; import { SessionDetailsOverlay } from "@/components/session-details-overlay"; import { SessionPromptComposer } from "@/components/session-prompt-composer"; +import { ActionBar } from "@/components/action-bar"; import { QueuedPromptStack } from "@/components/queued-prompt-stack"; import { SessionRightSidebar } from "@/components/session-right-sidebar"; -import { - Group as PanelGroup, - Panel, - Separator as PanelResizeHandle, - useDefaultLayout, -} from "react-resizable-panels"; +import { SessionScopeProvider } from "@/components/session-scope-provider"; +import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; import { TerminalPanel } from "@/components/terminal-panel"; import { archiveSession } from "@/lib/archive-session"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; +import { sessionActionErrorMessage } from "@/lib/session-action-error"; +import { toast } from "sonner"; import { isArchivedSessionListKey, isUnarchivedSessionListKey, @@ -29,41 +28,35 @@ import { type SessionListResponse, } from "@/lib/session-list"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { - DEFAULT_MODEL, - getDefaultReasoningEffort, - type ReasoningEffort, - type ValidModel, -} from "@open-inspect/shared/models"; +import { DEFAULT_MODEL, type ReasoningEffort, type ValidModel } from "@open-inspect/shared/models"; import type { ModelPreference } from "@/lib/model-selection"; -import type { HarnessId } from "@open-inspect/shared/harnesses"; +import { getHarnessReasoningConfig, type HarnessId } from "@open-inspect/shared/harnesses"; import { resolveHarnessModelSelection } from "@/lib/session-harness"; import { useEnabledModels } from "@/hooks/use-enabled-models"; import { useSessionDiffs } from "@/hooks/use-session-diffs"; -import { resolveDiffSelection, type DiffSelection } from "@/lib/session-diffs"; +import { resolveDiffSelection } from "@/lib/session-diffs"; +import { SessionFileLinksProvider } from "@/lib/session-file-links"; import type { SessionDiffFile, SessionDiffRepository, } from "@open-inspect/shared/types/session-diffs"; import { SessionChangesPanel } from "@/components/session-changes-panel"; -import { - SESSION_CHANGES_LAYOUT_ID, - SessionDesktopLayout, -} from "@/components/session-desktop-layout"; +import { SessionDesktopLayout } from "@/components/session-desktop-layout"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; -import { useBrowserLayoutStorage } from "@/hooks/use-browser-layout-storage"; import { focusSessionDetailsTrigger } from "@/lib/session-details-focus"; import { useSessionParticipantProfiles } from "@/hooks/use-session-participant-profiles"; import { useSessionDetailsSidebar } from "@/hooks/use-session-details-sidebar"; +import { useSessionDiffSelection } from "@/hooks/use-session-diff-selection"; +import { useSessionInspectorTab } from "@/hooks/use-session-inspector-tab"; import { findLatestTerminalMessageId } from "@/lib/session-read-state"; import { useMarkSessionRead } from "@/hooks/use-mark-session-read"; import { usePromptInput } from "@/hooks/use-prompt-input"; import { formatSessionCost } from "@/lib/session-cost"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; -import { useSessionSnapshot } from "./session-snapshot-provider"; +import { useSessionSnapshot, useRefreshSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; -import { resolveSessionCapabilities } from "@/lib/session-capabilities"; +import { SandboxShutdownBanner } from "@/components/sandbox-shutdown-banner"; +import { sandboxPromptBlockReason } from "@open-inspect/shared/types/sandbox-shutdown"; type SessionState = ReturnType["sessionState"]; @@ -71,12 +64,38 @@ const TERMINAL_VISIBLE_STORAGE_KEY = "terminal-visible"; const DEFAULT_SESSION_STATUS = "created" as const; export default function SessionPage() { - const { shortcuts } = useKeyboardShortcuts(); - const { hasPermission } = useCurrentUserAuthorization(); - const capabilities = useMemo(() => resolveSessionCapabilities(hasPermission), [hasPermission]); const initialSnapshot = useSessionSnapshot(); + const socket = useSessionSocket(initialSnapshot.session.id, initialSnapshot); + if (socket.sessionGone) notFound(); + return ( + + + + ); +} + +function SessionContent({ + initialSnapshot, + socket, +}: { + initialSnapshot: ReturnType; + socket: ReturnType; +}) { + const { shortcuts } = useKeyboardShortcuts(); + const refreshSnapshot = useRefreshSessionSnapshot(); const sessionId = initialSnapshot.session.id; + const scope = + initialSnapshot.session.visibility === undefined + ? undefined + : { + ownerTeamId: initialSnapshot.session.ownerTeamId ?? null, + ownerUserId: initialSnapshot.session.ownerUserId ?? null, + visibility: initialSnapshot.session.visibility, + collaborators: initialSnapshot.session.collaborators ?? [], + onUpdated: refreshSnapshot, + }; const { + capabilities, connected, connecting, reconnecting, @@ -86,6 +105,7 @@ export default function SessionPage() { connectionError, sessionState, sandboxError, + boot, events, participants, artifacts, @@ -93,14 +113,14 @@ export default function SessionPage() { canManageBudget, isProcessing, promptQueue, - loadingHistory, sendPrompt, cancelPrompt, stopExecution, + recoverShutdown, sendTyping, reconnect, loadOlderEvents, - } = useSessionSocket(sessionId, initialSnapshot, capabilities); + } = socket; const latestTerminalMessageId = useMemo(() => findLatestTerminalMessageId(events), [events]); useMarkSessionRead(sessionId, latestTerminalMessageId); const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles( @@ -125,6 +145,7 @@ export default function SessionPage() { }); // Fixed at create; per-message model overrides must stay within it. const sessionHarness = sessionState?.harness ?? initialSnapshot.session.harness; + const sandboxBlockReason = sandboxPromptBlockReason(sessionState?.sandboxPreservation); const { selectedModel, reasoningEffort, @@ -153,7 +174,7 @@ export default function SessionPage() { reasoningEffort, loadingEnabledModels, sessionState?.status ?? DEFAULT_SESSION_STATUS, - ready && capabilities.collaborate && !sessionState?.budgetExhausted, + ready && capabilities.collaborate && !sessionState?.budgetExhausted && !sandboxBlockReason, shortcuts["send-prompt"] ); const [cancellingPromptIds, setCancellingPromptIds] = useState>(new Set()); @@ -190,8 +211,6 @@ export default function SessionPage() { ); const [selectedMediaArtifactId, setSelectedMediaArtifactId] = useState(null); - const [selectedDiff, setSelectedDiff] = useState(null); - const diffReturnFocusRef = useRef(null); const { state: diffState, isLoading: diffLoading } = useSessionDiffs(sessionId); const isBelowLg = useMediaQuery("(max-width: 1023px)"); @@ -201,6 +220,12 @@ export default function SessionPage() { const { isOpen: isDesktopDetailsOpen, toggle: toggleDesktopDetails } = useSessionDetailsSidebar(); const detailsButtonRef = useRef(null); const actionsButtonRef = useRef(null); + const desktopDetailsButtonRef = useRef(null); + const { + tab: inspectorTab, + selectTab: selectInspectorTab, + showTab: showInspectorTab, + } = useSessionInspectorTab(); // Terminal panel state. Starts closed so the server and the client render the // same markup, then adopts the stored preference after hydration. @@ -242,10 +267,32 @@ export default function SessionPage() { const openMobileDetails = useCallback(() => { setIsDetailsOpen(true); }, []); + const openMobileMedia = useCallback(() => { + setIsDetailsOpen(true); + // Media lives in Info's Artifacts section. Showing it is navigation, so the + // viewer's remembered tab stays as it was. + showInspectorTab("info"); + }, [showInspectorTab]); const focusDetailsTrigger = useCallback( () => focusSessionDetailsTrigger(isPhone, actionsButtonRef.current, detailsButtonRef.current), [isPhone] ); + const handleDiffOpen = useCallback(() => { + setIsDetailsOpen(false); + // The diff is navigated from the Changes list. Showing it is navigation, so the + // viewer's remembered tab stays as it was. + showInspectorTab("changes"); + }, [showInspectorTab]); + const focusAfterDiff = useCallback(() => { + if (isBelowLg) focusDetailsTrigger(); + else desktopDetailsButtonRef.current?.focus(); + }, [focusDetailsTrigger, isBelowLg]); + const { + selectedDiff, + openDiff: openDiffSelection, + selectDiff, + closeDiff, + } = useSessionDiffSelection({ onOpen: handleDiffOpen, focusFallback: focusAfterDiff }); useEffect(() => { if (isBelowLg) return; @@ -257,10 +304,6 @@ export default function SessionPage() { artifacts.filter((artifact) => artifact.type === "screenshot" || artifact.type === "video"), [artifacts] ); - const selectedMediaArtifact = useMemo( - () => mediaArtifacts.find((artifact) => artifact.id === selectedMediaArtifactId) ?? null, - [mediaArtifacts, selectedMediaArtifactId] - ); const primaryRepo = sessionState?.repositories?.[0] ?? (sessionState?.repoOwner && sessionState?.repoName @@ -274,42 +317,11 @@ export default function SessionPage() { : null, [diffState, selectedDiff] ); - const changesLayoutStorage = useBrowserLayoutStorage(); - const changesLayout = useDefaultLayout({ - id: SESSION_CHANGES_LAYOUT_ID, - panelIds: - resolvedDiff && diffState && !isBelowLg - ? ["session-main", "session-changes"] - : ["session-main"], - storage: changesLayoutStorage, - }); - const openDiff = useCallback((repository: SessionDiffRepository, file: SessionDiffFile) => { - const selection = { repositoryPosition: repository.position, path: file.path }; - diffReturnFocusRef.current = selection; - setSelectedDiff(selection); - setIsDetailsOpen(false); - }, []); - const closeDiff = useCallback(() => { - const returnSelection = diffReturnFocusRef.current; - setSelectedDiff(null); - requestAnimationFrame(() => { - if (!isBelowLg && returnSelection) { - const row = Array.from( - document.querySelectorAll("button[data-diff-path]") - ).find( - (candidate) => - candidate.dataset.diffRepositoryPosition === - String(returnSelection.repositoryPosition) && - candidate.dataset.diffPath === returnSelection.path - ); - if (row) { - row.focus(); - return; - } - } - focusDetailsTrigger(); - }); - }, [focusDetailsTrigger, isBelowLg]); + const openDiff = useCallback( + (repository: SessionDiffRepository, file: SessionDiffFile) => + openDiffSelection({ repositoryPosition: repository.position, path: file.path }), + [openDiffSelection] + ); const sessionWorkspace = (
@@ -320,18 +332,22 @@ export default function SessionPage() { minSize="30%" style={{ minHeight: 0, overflow: "clip" }} > - + + + {showTerminal && ( <> @@ -349,6 +365,19 @@ export default function SessionPage() { onRemove={handleRemoveQueuedPrompt} capabilities={capabilities} /> + {!capabilities.collaborate && capabilities.read && ( +
+ +
+ )} {capabilities.collaborate && ( )} + {capabilities.read && ( + + )} + {/* Main content */}
{!isBelowLg ? ( @@ -455,7 +496,7 @@ export default function SessionPage() { workspace={sessionWorkspace} sidebar={ } changes={ @@ -480,13 +524,12 @@ export default function SessionPage() { state={diffState} resolved={resolvedDiff} onClose={closeDiff} - onSelect={setSelectedDiff} + onSelect={selectDiff} + sidebarShowsFileList={isDesktopDetailsOpen && inspectorTab === "changes"} capabilities={capabilities} /> ) : null } - defaultLayout={changesLayout.defaultLayout} - onLayoutChanged={changesLayout.onLayoutChanged} /> ) : ( <> @@ -506,7 +549,10 @@ export default function SessionPage() { selectedDiff={selectedDiff} onOpenDiff={openDiff} canManageBudget={canManageBudget} + activeTab={inspectorTab} + onTabChange={selectInspectorTab} capabilities={capabilities} + scope={scope} /> )} @@ -532,7 +578,10 @@ export default function SessionPage() { selectedDiff={selectedDiff} onOpenDiff={openDiff} canManageBudget={canManageBudget} + activeTab={inspectorTab} + onTabChange={selectInspectorTab} capabilities={capabilities} + scope={scope} /> )} @@ -550,7 +599,7 @@ export default function SessionPage() { state={diffState} resolved={resolvedDiff} onClose={closeDiff} - onSelect={setSelectedDiff} + onSelect={selectDiff} capabilities={capabilities} /> )} @@ -560,13 +609,9 @@ export default function SessionPage() { { - if (!open) { - setSelectedMediaArtifactId(null); - } - }} + artifacts={mediaArtifacts} + selectedArtifactId={selectedMediaArtifactId} + onSelectArtifact={setSelectedMediaArtifactId} />
); @@ -608,10 +653,10 @@ function useSessionListActions(sessionId: string) { ); mutate(isUnarchivedSessionListKey); } else { - console.error("Failed to unarchive session"); + toast.error(await sessionActionErrorMessage(r, "Failed to unarchive session")); } }), - { throwOnError: false } + { throwOnError: false, onError: () => toast.error("Failed to unarchive session") } ); return { handleArchive, handleUnarchive }; @@ -628,7 +673,7 @@ function useModelSelection(sessionState: SessionState, harness: HarnessId) { const { enabledModels, enabledModelOptions, loading: loadingEnabledModels } = useEnabledModels(); const sessionModel = sessionState?.model ?? DEFAULT_MODEL; const sessionReasoningEffort = - sessionState?.reasoningEffort ?? getDefaultReasoningEffort(sessionModel); + sessionState?.reasoningEffort ?? getHarnessReasoningConfig(harness, sessionModel)?.default; const { model: selectedModel, reasoningEffort, @@ -656,9 +701,15 @@ function useModelSelection(sessionState: SessionState, harness: HarnessId) { sessionReasoningEffort, ] ); - const handleModelChange = useCallback((model: ValidModel) => { - setModelPreferenceDraft({ model, reasoningEffort: getDefaultReasoningEffort(model) }); - }, []); + const handleModelChange = useCallback( + (model: ValidModel) => { + setModelPreferenceDraft({ + model, + reasoningEffort: getHarnessReasoningConfig(harness, model)?.default, + }); + }, + [harness] + ); const setReasoningEffort = useCallback( (nextReasoningEffort: ReasoningEffort | undefined) => { diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.test.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.test.tsx new file mode 100644 index 0000000000..6c68eec3c4 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { Component, type ReactNode } from "react"; +import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { + SessionSnapshotProvider, + useSessionSnapshot, + useRefreshSessionSnapshot, +} from "./session-snapshot-provider"; + +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: { id: "user_actor" } } }), +})); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, + redirect: () => { + throw new Error("NEXT_REDIRECT"); + }, +})); + +const snapshot: SessionSnapshot = { + session: { + id: "session-1", + title: null, + repoOwner: null, + repoName: null, + baseBranch: null, + branchName: null, + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + harness: "opencode", + ownerTeamId: "team_old", + visibility: "private", + collaborators: [], + capabilities: { + canRead: true, + canCollaborate: true, + canManageLifecycle: true, + canDelete: true, + canSandbox: true, + canManageCollaborators: true, + canChangeVisibility: true, + }, + }, + artifacts: [], + timeline: { events: [], hasMore: false, cursor: null }, + promptQueue: [], +}; + +function Probe() { + const current = useSessionSnapshot(); + const refresh = useRefreshSessionSnapshot(); + return ( + <> +

+ {current.session.ownerTeamId} / {current.session.visibility} +

+

{current.session.collaborators?.join(",")}

+ + + + ); +} + +class Boundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?

{this.state.error.message}

: this.props.children; + } +} + +beforeEach(() => vi.resetAllMocks()); +afterEach(() => cleanup()); + +describe("SessionSnapshotProvider", () => { + it("renders not-found instead of session content when a refreshed deep link is invisible", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ error: "Session not found" }, { status: 404 }) + ); + render( + + + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.getByText("NEXT_NOT_FOUND")).toBeInTheDocument()); + expect(screen.queryByText("team_old / private")).not.toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); + it("prefers a new SSR snapshot to previously refreshed data", async () => { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ + ...snapshot, + session: { ...snapshot.session, visibility: "team" }, + }) + ); + const { rerender } = render( + + + , + { + wrapper: ({ children }) => ( + new Map() }}>{children} + ), + } + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.getByText("team_old / team")).toBeInTheDocument()); + const incoming = { + ...snapshot, + session: { ...snapshot.session, visibility: "workspace" as const }, + }; + rerender( + + + + ); + expect(screen.getByText("team_old / workspace")).toBeInTheDocument(); + }); + it("renders the SSR snapshot without an extra initial request", () => { + render( + new Map() }}> + + + + + ); + expect(screen.getByText("team_old / private")).toBeInTheDocument(); + expect(browserApiFetch).not.toHaveBeenCalled(); + }); + + it("refreshes scope, collaborators and capabilities even when the timestamp is unchanged", async () => { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ + ...snapshot, + session: { + ...snapshot.session, + visibility: "team", + collaborators: ["user_added"], + capabilities: { ...snapshot.session.capabilities, canChangeVisibility: false }, + }, + }) + ); + render( + new Map(), dedupingInterval: 0 }}> + + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.getByText("team_old / team")).toBeInTheDocument()); + expect(screen.getByText("user_added")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Change visibility" })).toBeDisabled(); + expect(browserApiFetch).toHaveBeenCalledWith("/api/sessions/session-1"); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx index f2f23f6779..bde73aa8ff 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx @@ -1,9 +1,23 @@ "use client"; -import { createContext, useContext } from "react"; -import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; +import { createContext, useContext, useState } from "react"; +import { notFound, redirect } from "next/navigation"; +import { + sessionSnapshotSchema, + type SessionSnapshot, +} from "@open-inspect/shared/types/server-messages"; +import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; -const SessionSnapshotContext = createContext(null); +const SessionSnapshotContext = createContext<{ + snapshot: SessionSnapshot; + refresh: () => Promise; +} | null>(null); + +class SnapshotRequestError extends Error { + constructor(readonly status: number) { + super(`Unable to refresh session (${status})`); + } +} export function SessionSnapshotProvider({ snapshot, @@ -12,13 +26,49 @@ export function SessionSnapshotProvider({ snapshot: SessionSnapshot; children: React.ReactNode; }) { + const [state, setState] = useState<{ + source: SessionSnapshot; + current: SessionSnapshot; + error: unknown; + }>({ source: snapshot, current: snapshot, error: null }); + if (state.source !== snapshot) setState({ source: snapshot, current: snapshot, error: null }); + const path: BrowserApiPath = `/api/sessions/${encodeURIComponent(snapshot.session.id)}`; + + async function refresh() { + try { + const response = await browserApiFetch(path); + if (!response.ok) throw new SnapshotRequestError(response.status); + const current = sessionSnapshotSchema.parse(await response.json()); + setState({ source: snapshot, current, error: null }); + } catch (error) { + setState((current) => ({ ...current, error })); + throw error; + } + } + + if (state.error instanceof SnapshotRequestError) { + if (state.error.status === 404) notFound(); + if (state.error.status === 401) redirect("/login"); + } + const current = state.source === snapshot ? state.current : snapshot; + const value = state.error + ? { ...current, session: { ...current.session, capabilities: undefined } } + : current; return ( - {children} + + {children} + ); } export function useSessionSnapshot(): SessionSnapshot { - const snapshot = useContext(SessionSnapshotContext); - if (!snapshot) throw new Error("Session snapshot provider is missing"); - return snapshot; + const context = useContext(SessionSnapshotContext); + if (!context) throw new Error("Session snapshot provider is missing"); + return context.snapshot; +} + +export function useRefreshSessionSnapshot(): () => Promise { + const context = useContext(SessionSnapshotContext); + if (!context) throw new Error("Session snapshot provider is missing"); + return context.refresh; } diff --git a/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx new file mode 100644 index 0000000000..10b5c65b8d --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx @@ -0,0 +1,901 @@ +// @vitest-environment jsdom +/// + +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionListItem } from "@/lib/session-list"; +import type * as ActiveTeam from "@/hooks/use-active-team"; +import { ActiveTeamProvider } from "@/hooks/use-active-team"; +import { DEFAULT_SESSION_DISCOVERY_QUERY } from "@/lib/session-discovery"; +import SessionsPage from "./page"; + +expect.extend(matchers); + +const { mockReplace, mockUseSessionDiscovery, mockSearchParamsState, mockPermissions } = vi.hoisted( + () => ({ + mockReplace: vi.fn(), + mockUseSessionDiscovery: vi.fn(), + mockSearchParamsState: { value: new URLSearchParams() }, + mockPermissions: new Set(), + }) +); + +const teamMocks = vi.hoisted(() => ({ + activeTeamId: "all-my-teams" as string | null, + loading: false, + error: undefined as unknown, + setActiveTeam: vi.fn(), + roleKey: "member", + canListAllTeams: false, + realContext: false, +})); + +vi.mock("@/hooks/use-teams", async (importOriginal) => ({ + ...(await importOriginal>()), + useMeTeams: () => ({ + teams: [ + { id: "team_alpha", name: "Alpha", role: "member", archivedAt: null }, + { id: "team_beta", name: "Beta", role: "lead", archivedAt: null }, + ], + loading: teamMocks.loading, + error: teamMocks.error, + requireTeamOnCreate: false, + canListAllTeams: teamMocks.canListAllTeams, + hasData: !teamMocks.loading && !teamMocks.error, + }), +})); + +vi.mock("@/hooks/use-active-team", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useActiveTeam: () => + teamMocks.realContext + ? original.useActiveTeam() + : { + ...teamMocks, + activeTeamId: teamMocks.activeTeamId?.startsWith("team_") + ? teamMocks.activeTeamId + : null, + scope: + teamMocks.activeTeamId === null + ? "workspace" + : teamMocks.activeTeamId === "all-teams" + ? "all" + : undefined, + teams: [ + { id: "team_alpha", name: "Alpha", role: "member" }, + { id: "team_beta", name: "Beta", role: "lead" }, + ], + requireTeamOnCreate: false, + }, + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => mockSearchParamsState.value, +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: Omit, "href"> & { + href: string | { pathname: string; query?: Record }; + }) => { + const resolved = + typeof href === "string" + ? href + : `${href.pathname}${ + href.query && Object.keys(href.query).length > 0 + ? `?${new URLSearchParams(href.query).toString()}` + : "" + }`; + return ( + + {children} + + ); + }, +})); + +vi.mock("@/components/sidebar-layout", () => ({ + CollapsedSidebarControls: () =>
, + useSidebarContext: () => ({ isOpen: true }), +})); + +vi.mock("@/hooks/use-session-discovery", () => ({ + useSessionDiscovery: mockUseSessionDiscovery, +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + loading: false, + authorization: { role: { key: teamMocks.roleKey } }, + hasPermission: (permission: string) => mockPermissions.has(permission), + }), +})); + +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ + status: "authenticated", + data: { user: { id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }, + }), +})); + +vi.mock("@/hooks/use-environments", () => ({ + useEnvironments: () => ({ + environments: [{ id: "env-1", name: "Staging" }], + loading: false, + error: undefined, + }), +})); + +vi.mock("@/hooks/use-repos", () => ({ + useRepos: () => ({ + repos: [ + { id: 1, fullName: "acme/web-app", owner: "acme", name: "web-app" }, + { id: 2, fullName: "acme/api", owner: "acme", name: "api" }, + ], + loading: false, + error: undefined, + }), +})); + +function session(id: string, overrides: Partial = {}): SessionListItem { + return { + id, + title: `Session ${id}`, + repoOwner: "acme", + repoName: "web-app", + harness: "opencode", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + baseBranch: "main", + status: "completed", + parentSessionId: null, + spawnSource: "user", + spawnDepth: 0, + automationId: null, + automationRunId: null, + scmLogin: "octocat", + userId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + totalCost: 0, + activeDurationMs: 0, + messageCount: 0, + prCount: 0, + environmentId: null, + createdAt: 1_000, + updatedAt: Date.now() - 2 * 60 * 60 * 1000, + ...overrides, + }; +} + +const defaultHookResult = { + sessions: [session("one")], + loading: false, + loadingMore: false, + error: undefined, + hasMore: false, + loadMore: vi.fn(), + retry: vi.fn(), +}; + +function lastQuery() { + return mockUseSessionDiscovery.mock.calls.at(-1)?.[0]; +} + +function lastOptions() { + return mockUseSessionDiscovery.mock.calls.at(-1)?.[1]; +} + +function typeSearch(value: string) { + fireEvent.change(screen.getByRole("searchbox"), { target: { value } }); +} + +/** Opens a filter select from the keyboard and picks an option by its label. */ +function chooseOption(trigger: HTMLElement, option: string) { + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(screen.getByRole("option", { name: option })); +} + +// Radix Select uses pointer-capture APIs that jsdom doesn't implement. +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } +}); + +describe("SessionsPage", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockReplace.mockReset(); + mockSearchParamsState.value = new URLSearchParams(); + mockPermissions.clear(); + mockPermissions.add("sessions.read"); + mockPermissions.add("sessions.create"); + mockPermissions.add("repositories.read"); + mockUseSessionDiscovery.mockReset(); + mockUseSessionDiscovery.mockReturnValue(defaultHookResult); + teamMocks.activeTeamId = "all-my-teams"; + teamMocks.loading = false; + teamMocks.error = undefined; + teamMocks.roleKey = "member"; + teamMocks.canListAllTeams = false; + teamMocks.setActiveTeam.mockReset(); + teamMocks.realContext = false; + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders the header, search, filters, and result rows that open the session", () => { + mockUseSessionDiscovery.mockReturnValue({ + ...defaultHookResult, + sessions: [ + session("multi", { + title: "Cross-repo refactor", + status: "active", + environmentId: "env-1", + repositories: [ + { repoOwner: "acme", repoName: "web-app", repoId: 1, baseBranch: "main" }, + { repoOwner: "acme", repoName: "api", repoId: 2, baseBranch: "main" }, + { repoOwner: "partner", repoName: "sdk", repoId: 3, baseBranch: "main" }, + ], + }), + session("child", { + title: null, + parentSessionId: "multi", + spawnSource: "agent", + userId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + scmLogin: null, + }), + ], + hasMore: true, + }); + + render(); + + expect(screen.getByRole("heading", { name: "Sessions" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "New session" })).toHaveAttribute("href", "/"); + expect( + screen.getByRole("searchbox", { name: "Search sessions by title, ID or repository" }) + ).toBeInTheDocument(); + expect(screen.getByRole("radiogroup", { name: "Creator" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Repository" })).toHaveTextContent( + "Any repository" + ); + expect(screen.getByRole("combobox", { name: "Environment" })).toHaveTextContent( + "Any environment" + ); + expect(screen.getByRole("combobox", { name: "Lifecycle" })).toHaveTextContent("Not archived"); + expect(screen.getByRole("combobox", { name: "Origin" })).toHaveTextContent("Any origin"); + expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Showing 2 sessions · More available"); + + const rows = within(screen.getByRole("list", { name: "Sessions" })).getAllByRole("link"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveAttribute( + "href", + "/session/multi?repoOwner=acme&repoName=web-app&title=Cross-repo+refactor" + ); + expect(rows[0]).toHaveTextContent("Cross-repo refactor"); + expect(rows[0]).toHaveTextContent("Active"); + expect(rows[0]).toHaveTextContent("acme/web-app"); + expect(rows[0]).toHaveTextContent("acme/api"); + expect(within(rows[0]).getByTitle("partner/sdk")).toHaveTextContent("+1"); + expect(rows[0]).toHaveTextContent("Staging"); + expect(rows[0]).toHaveTextContent("by octocat"); + expect(rows[0]).toHaveTextContent("Started by a person"); + expect(rows[0]).toHaveTextContent("2h"); + + expect(rows[1]).toHaveTextContent("Untitled session"); + expect(rows[1]).toHaveTextContent("by you"); + expect(rows[1]).toHaveTextContent("Agent sub-task"); + expect(within(rows[1]).getByTitle("Spawned from session multi")).toHaveTextContent("Sub-task"); + + expect(screen.getByRole("button", { name: "Load more sessions" })).toBeInTheDocument(); + expect(lastQuery()).toEqual({ + ...DEFAULT_SESSION_DISCOVERY_QUERY, + q: "", + creator: "all", + repository: null, + environmentId: null, + lifecycle: "nonarchived", + origin: null, + }); + }); + + it("shows each result's pull request state next to its title", () => { + mockUseSessionDiscovery.mockReturnValue({ + ...defaultHookResult, + sessions: [ + session("merged", { + prCount: 1, + pullRequestSummary: { total: 1, open: 0, draft: 0, merged: 1, closed: 0 }, + }), + session("mixed", { + prCount: 3, + pullRequestSummary: { total: 3, open: 1, draft: 1, merged: 1, closed: 0 }, + }), + session("no-prs"), + ], + }); + + render(); + + const rows = within(screen.getByRole("list", { name: "Sessions" })).getAllByRole("link"); + expect(within(rows[0]).getByTestId("pr-state-merged")).toHaveAccessibleName("PR merged"); + expect(within(rows[1]).getByTestId("pr-state-open")).toHaveAccessibleName("3 PRs · 2 open"); + expect(within(rows[2]).queryByTestId(/^pr-state-/)).not.toBeInTheDocument(); + // The state is announced as part of the row link, ahead of the title. + expect(rows[0]).toHaveAccessibleName(/^PR merged\s*Session merged/); + expect(rows[1]).toHaveAccessibleName(/^3 PRs · 2 open\s*Session mixed/); + expect(rows[2]).toHaveAccessibleName(/^Session no-prs/); + }); + + it("restores every control from a shared URL and keeps unknown selections visible", () => { + mockSearchParamsState.value = new URLSearchParams( + "q=login&createdBy=me&repoOwner=partner&repoName=sdk&environmentId=env-gone&lifecycle=archived&origin=automation" + ); + mockUseSessionDiscovery.mockReturnValue({ + ...defaultHookResult, + sessions: [session("archived-one", { status: "archived" })], + }); + + render(); + + expect(screen.getByRole("searchbox")).toHaveValue("login"); + expect(screen.getByRole("radio", { name: "Mine" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("combobox", { name: "Repository" })).toHaveTextContent("partner/sdk"); + expect(screen.getByRole("combobox", { name: "Environment" })).toHaveTextContent("env-gone"); + expect(screen.getByRole("combobox", { name: "Lifecycle" })).toHaveTextContent("Archived"); + expect(screen.getByRole("combobox", { name: "Origin" })).toHaveTextContent("Automation run"); + expect(screen.getByRole("button", { name: "Clear filters" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Manage archived sessions" })).toHaveAttribute( + "href", + "/settings?tab=data-controls" + ); + const rows = within(screen.getByRole("list", { name: "Sessions" })).getAllByRole("link"); + expect(rows[0]).toHaveTextContent("Archived"); + expect(lastQuery()).toEqual({ + ...DEFAULT_SESSION_DISCOVERY_QUERY, + q: "login", + creator: "mine", + repository: { repoOwner: "partner", repoName: "sdk" }, + environmentId: "env-gone", + lifecycle: "archived", + origin: "automation", + }); + }); + + it("debounces search text into the URL and writes filter changes immediately", () => { + render(); + + typeSearch(" fix login "); + expect(mockReplace).not.toHaveBeenCalled(); + act(() => vi.runOnlyPendingTimers()); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=fix+login", { scroll: false }); + + // Until that navigation lands, later control changes still carry it. + chooseOption(screen.getByRole("combobox", { name: "Repository" }), "acme/api"); + expect(mockReplace).toHaveBeenLastCalledWith( + "/sessions?q=fix+login&repoOwner=acme&repoName=api", + { scroll: false } + ); + + mockSearchParamsState.value = new URLSearchParams("q=fix login&repoOwner=acme&repoName=api"); + render(); + mockReplace.mockReset(); + const [, page] = screen.getAllByRole("combobox", { name: "Lifecycle" }); + chooseOption(page, "All"); + expect(mockReplace).toHaveBeenLastCalledWith( + "/sessions?q=fix+login&repoOwner=acme&repoName=api&lifecycle=all", + { scroll: false } + ); + chooseOption(screen.getAllByRole("combobox", { name: "Origin" })[1], "GitHub bot"); + expect(mockReplace).toHaveBeenLastCalledWith( + "/sessions?q=fix+login&repoOwner=acme&repoName=api&lifecycle=all&origin=github-bot", + { scroll: false } + ); + fireEvent.click(screen.getAllByRole("radio", { name: "Mine" })[1]); + expect(mockReplace).toHaveBeenLastCalledWith( + "/sessions?q=fix+login&createdBy=me&repoOwner=acme&repoName=api&lifecycle=all&origin=github-bot", + { scroll: false } + ); + }); + + it("sends a reversal made before the previous navigation lands", () => { + render(); + + // The URL still shows the default view while the first replace is in flight. + chooseOption(screen.getByRole("combobox", { name: "Lifecycle" }), "All"); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?lifecycle=all", { scroll: false }); + expect(screen.getByRole("combobox", { name: "Lifecycle" })).toHaveTextContent("All"); + expect(screen.getByRole("button", { name: "Clear filters" })).toBeInTheDocument(); + chooseOption(screen.getByRole("combobox", { name: "Lifecycle" }), "Not archived"); + expect(mockReplace).toHaveBeenCalledTimes(2); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions", { scroll: false }); + expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument(); + + // Re-selecting the state already written is not a navigation. + chooseOption(screen.getByRole("combobox", { name: "Lifecycle" }), "Not archived"); + expect(mockReplace).toHaveBeenCalledTimes(2); + }); + + it("keeps an environment ID that matches the Any sentinel distinct from Any", () => { + mockSearchParamsState.value = new URLSearchParams("environmentId=__any__"); + render(); + + const environment = screen.getByRole("combobox", { name: "Environment" }); + expect(environment).toHaveTextContent("__any__"); + chooseOption(environment, "Any environment"); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions", { scroll: false }); + }); + + it("follows URL changes from browser navigation", () => { + const { rerender } = render(); + + mockSearchParamsState.value = new URLSearchParams("q=weekly&lifecycle=all"); + rerender(); + + expect(screen.getByRole("searchbox")).toHaveValue("weekly"); + expect(screen.getByRole("combobox", { name: "Lifecycle" })).toHaveTextContent("All"); + expect(lastQuery()).toMatchObject({ q: "weekly", lifecycle: "all" }); + act(() => vi.runOnlyPendingTimers()); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("clears the search and every filter back to the default view", () => { + mockSearchParamsState.value = new URLSearchParams("q=login&origin=automation"); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?origin=automation", { + scroll: false, + }); + expect(screen.getByRole("searchbox")).toHaveValue(""); + + mockReplace.mockReset(); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions", { scroll: false }); + }); + + it("shows the loading, empty, no-match, and error states without hiding the controls", () => { + mockUseSessionDiscovery.mockReturnValue({ ...defaultHookResult, sessions: [], loading: true }); + const { rerender } = render(); + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("Loading sessions"); + // The live status line announces loading, so the skeleton stays out of the a11y tree. + expect(screen.getByTestId("sessions-skeleton")).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByRole("searchbox")).toBeInTheDocument(); + + mockUseSessionDiscovery.mockReturnValue({ ...defaultHookResult, sessions: [] }); + rerender(); + expect(screen.queryByTestId("sessions-skeleton")).not.toBeInTheDocument(); + expect(status).toHaveTextContent("No sessions yet"); + expect(screen.getAllByText("No sessions yet")).toHaveLength(2); + expect(screen.getAllByRole("link", { name: "New session" })).toHaveLength(2); + + mockSearchParamsState.value = new URLSearchParams("q=nothing"); + rerender(); + // The same live region announces the no-match state; it stays mounted. + expect(screen.getByRole("status")).toBe(status); + expect(status).toHaveTextContent("No sessions match these filters"); + expect(screen.queryByText("No sessions yet")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Clear filters" })).toHaveLength(2); + + const retry = vi.fn(); + mockUseSessionDiscovery.mockReturnValue({ + ...defaultHookResult, + sessions: [session("stale")], + error: new Error("failed"), + retry, + }); + rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Couldn't load sessions."); + expect(screen.getByRole("searchbox")).toHaveValue("nothing"); + expect(screen.getByText("Session stale")).toBeInTheDocument(); + expect(status).toHaveTextContent("Showing 1 session"); + expect(screen.queryByText("No sessions match these filters")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalled(); + }); + + it("loads more from the paging control", () => { + const loadMore = vi.fn(); + mockUseSessionDiscovery.mockReturnValue({ ...defaultHookResult, hasMore: true, loadMore }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Load more sessions" })); + expect(loadMore).toHaveBeenCalled(); + }); + + it("hides the controls, explains, and requests nothing when the user cannot read sessions", () => { + mockPermissions.clear(); + render(); + + expect(screen.getByRole("alert")).toHaveTextContent( + "You do not have permission to view sessions." + ); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "New session" })).not.toBeInTheDocument(); + expect(lastOptions()).toEqual({ enabled: false }); + }); + + it("refuses a link with unsupported filters instead of showing a wider result set", () => { + // `status` is a valid API filter the page has no control for; `origin` is + // a value the API itself rejects. Both refuse rather than widen the view. + mockSearchParamsState.value = new URLSearchParams("status=archived&origin=automations"); + render(); + + expect(screen.getByRole("alert")).toHaveTextContent( + "This link has unsupported filters (status, origin), so no sessions are shown." + ); + expect(lastOptions()).toEqual({ enabled: false }); + expect(screen.getByRole("status")).toHaveTextContent("No sessions shown"); + expect(screen.queryByRole("list", { name: "Sessions" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reset filters" })); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions", { scroll: false }); + }); + + it("keeps typed text verbatim and composes with filter changes while a navigation is in flight", () => { + render(); + + // A trailing space is committed trimmed but never stripped from the box. + typeSearch("fix "); + act(() => vi.runOnlyPendingTimers()); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=fix", { scroll: false }); + mockSearchParamsState.value = new URLSearchParams("q=fix"); + // (rerender simulates the navigation landing) + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "fix " } }); + expect(screen.getByRole("searchbox")).toHaveValue("fix "); + + // Keystrokes typed before the last navigation lands are not thrown away. + typeSearch("fix login"); + act(() => vi.runOnlyPendingTimers()); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=fix+login", { scroll: false }); + typeSearch("fix login now"); + expect(screen.getByRole("searchbox")).toHaveValue("fix login now"); + act(() => vi.runOnlyPendingTimers()); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=fix+login+now", { + scroll: false, + }); + + // A filter change carries the pending search and the previous filter change. + chooseOption(screen.getByRole("combobox", { name: "Lifecycle" }), "All"); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=fix+login+now&lifecycle=all", { + scroll: false, + }); + chooseOption(screen.getByRole("combobox", { name: "Origin" }), "Automation run"); + expect(mockReplace).toHaveBeenLastCalledWith( + "/sessions?q=fix+login+now&lifecycle=all&origin=automation", + { scroll: false } + ); + + // Clearing never resurrects the old filters from a late timer. + mockReplace.mockReset(); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + act(() => vi.runOnlyPendingTimers()); + expect(mockReplace).toHaveBeenCalledTimes(1); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions", { scroll: false }); + }); + + it.each([ + [null, undefined, "workspace", "Workspace"], + ["team_alpha", ["team_alpha"], undefined, "Alpha"], + ["all-my-teams", undefined, undefined, "All my teams"], + ["all-teams", undefined, "all", "All teams"], + ])( + "inherits active context %s when the URL has no team filter", + (activeTeamId, teamIds, scope, label) => { + teamMocks.activeTeamId = activeTeamId; + teamMocks.roleKey = "administrator"; + teamMocks.canListAllTeams = true; + mockSearchParamsState.value = new URLSearchParams( + "q=login&ownerFilter=started&visibility=team" + ); + render(); + + expect(lastQuery()).toMatchObject({ + q: "login", + teamIds, + scope, + ownerFilter: "started", + visibility: "team", + }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent(label!); + expect(mockReplace).not.toHaveBeenCalled(); + } + ); + + it("keeps an explicit unknown team URL authoritative without widening", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams( + "teamIds[]=team_missing&ownerFilter=participating&visibility=private&createdBy=me" + ); + render(); + + expect(lastQuery()).toMatchObject({ + teamIds: ["team_missing"], + scope: undefined, + creator: "mine", + ownerFilter: "participating", + visibility: "private", + }); + expect(lastOptions()).toEqual({ enabled: true }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("team_missing"); + expect(screen.getByRole("combobox", { name: "Owner" })).toHaveTextContent("Participating"); + expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Private"); + expect(teamMocks.setActiveTeam).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("preserves repeated selected teams from a link", () => { + mockSearchParamsState.value = new URLSearchParams( + "teamIds[]=team_alpha&teamIds[]=team_missing" + ); + render(); + expect(lastQuery()).toMatchObject({ teamIds: ["team_alpha", "team_missing"] }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Alpha, team_missing"); + expect(lastOptions()).toEqual({ enabled: true }); + }); + + it.each(["workspace", "all"])("honors explicit scope=%s over the active team", (scope) => { + teamMocks.activeTeamId = "team_alpha"; + teamMocks.roleKey = "administrator"; + teamMocks.canListAllTeams = true; + mockSearchParamsState.value = new URLSearchParams({ scope }); + render(); + expect(lastQuery()).toMatchObject({ scope, teamIds: undefined }); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("updates team context on switcher changes while preserving filters and pending search", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams( + "teamIds[]=team_missing&createdBy=me&lifecycle=archived&origin=automation&ownerFilter=participating&visibility=private" + ); + const { rerender } = render(); + typeSearch("pending login"); + teamMocks.activeTeamId = "team_beta"; + rerender(); + + const href = mockReplace.mock.calls.at(-1)?.[0]; + expect(href).toBeDefined(); + const params = new URL(href, "https://example.com").searchParams; + expect(params.getAll("teamIds[]")).toEqual(["team_beta"]); + expect(params.get("scope")).toBeNull(); + expect(params.get("q")).toBe("pending login"); + expect(params.get("createdBy")).toBe("me"); + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("visibility")).toBe("private"); + expect(params.get("lifecycle")).toBe("archived"); + expect(params.get("origin")).toBe("automation"); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Beta"); + act(() => vi.runOnlyPendingTimers()); + expect( + new URL(mockReplace.mock.calls.at(-1)![0], "https://example.com").searchParams.getAll( + "teamIds[]" + ) + ).toEqual(["team_beta"]); + }); + + it("preserves in-flight filter changes when switching an inherited team context", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams("q=login"); + const { rerender } = render(); + chooseOption(screen.getByRole("combobox", { name: "Owner" }), "Participating"); + teamMocks.activeTeamId = "team_beta"; + rerender(); + + const params = new URL(mockReplace.mock.calls.at(-1)![0], "https://example.com").searchParams; + expect(params.getAll("teamIds[]")).toEqual(["team_beta"]); + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("q")).toBe("login"); + expect(screen.getByRole("combobox", { name: "Owner" })).toHaveTextContent("Participating"); + }); + + it("does not overwrite an explicit link when initial active-team loading settles", () => { + teamMocks.activeTeamId = null; + teamMocks.loading = true; + mockSearchParamsState.value = new URLSearchParams("teamIds[]=team_missing"); + const { rerender } = render(); + teamMocks.activeTeamId = "team_alpha"; + teamMocks.loading = false; + rerender(); + + expect(lastQuery()).toMatchObject({ teamIds: ["team_missing"] }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("team_missing"); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("does not treat the inherited context alone as a clearable filter", () => { + teamMocks.activeTeamId = "team_alpha"; + render(); + expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument(); + }); + + it("writes independent Owner and Visibility controls while retaining Creator Mine", () => { + mockSearchParamsState.value = new URLSearchParams("createdBy=me&teamIds[]=team_alpha"); + render(); + chooseOption(screen.getByRole("combobox", { name: "Owner" }), "Participating"); + chooseOption(screen.getByRole("combobox", { name: "Visibility" }), "Private"); + const params = new URL(mockReplace.mock.calls.at(-1)![0], "https://example.com").searchParams; + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("visibility")).toBe("private"); + expect(params.get("createdBy")).toBe("me"); + expect(params.getAll("teamIds[]")).toEqual(["team_alpha"]); + }); + + it("selects All my teams without falling back to the previously active team", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams("q=login&teamIds[]=team_alpha"); + const { rerender } = render(); + chooseOption(screen.getByRole("combobox", { name: "Team" }), "All my teams"); + expect(teamMocks.setActiveTeam).toHaveBeenCalledWith("all-my-teams"); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=login", { scroll: false }); + teamMocks.activeTeamId = "all-my-teams"; + mockSearchParamsState.value = new URLSearchParams("q=login"); + rerender(); + expect(lastQuery()).toMatchObject({ q: "login", teamIds: undefined, scope: undefined }); + }); + + it("uses the real provider's All my teams scope and preserves page filters after navigation", () => { + teamMocks.realContext = true; + localStorage.setItem("open-inspect-active-team", "team_alpha"); + mockSearchParamsState.value = new URLSearchParams( + "q=login&teamIds[]=team_alpha&createdBy=me&ownerFilter=participating&visibility=private" + ); + const { rerender } = render( + + + + ); + + chooseOption(screen.getByRole("combobox", { name: "Team" }), "All my teams"); + const href = mockReplace.mock.calls.at(-1)![0]; + const params = new URL(href, "https://example.com").searchParams; + expect(new URL(href, "https://example.com").pathname).toBe("/sessions"); + expect(params.getAll("teamIds[]")).toEqual([]); + expect(params.has("scope")).toBe(false); + expect(params.get("q")).toBe("login"); + expect(params.get("createdBy")).toBe("me"); + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("visibility")).toBe("private"); + mockSearchParamsState.value = params; + rerender( + + + + ); + + expect(lastQuery()).toMatchObject({ + q: "login", + creator: "mine", + ownerFilter: "participating", + visibility: "private", + teamIds: undefined, + scope: undefined, + }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("All my teams"); + expect(localStorage.getItem("open-inspect-active-team")).toBe("all-my-teams"); + }); + + it("protects explicit links through first-run hydration with the real provider", () => { + teamMocks.realContext = true; + teamMocks.loading = true; + localStorage.setItem("open-inspect-active-team", "team_alpha"); + mockSearchParamsState.value = new URLSearchParams("teamIds[]=team_missing&ownerFilter=started"); + const { rerender } = render( + + + + ); + expect(lastOptions()).toEqual({ enabled: false }); + teamMocks.loading = false; + rerender( + + + + ); + + expect(lastQuery()).toMatchObject({ + teamIds: ["team_missing"], + scope: undefined, + ownerFilter: "started", + }); + expect(lastOptions()).toEqual({ enabled: true }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("team_missing"); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("updates between aggregate scopes even though the real hook's activeTeamId stays null", () => { + teamMocks.activeTeamId = null; + mockSearchParamsState.value = new URLSearchParams("q=login&scope=workspace"); + const { rerender } = render(); + teamMocks.activeTeamId = "all-my-teams"; + rerender(); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=login", { scroll: false }); + teamMocks.roleKey = "administrator"; + teamMocks.canListAllTeams = true; + teamMocks.activeTeamId = "all-teams"; + rerender(); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=login&scope=all", { scroll: false }); + }); + + it.each([ + ["owner", false], + ["administrator", false], + ["member", true], + ["custom", true], + ] as const)( + "gates All teams by server capability for role %s with grant %s, not session permissions", + (roleKey, canListAllTeams) => { + teamMocks.roleKey = roleKey; + teamMocks.canListAllTeams = canListAllTeams; + mockPermissions.add("sessions.manage"); + render(); + fireEvent.keyDown(screen.getByRole("combobox", { name: "Team" }), { key: "Enter" }); + expect(screen.queryByRole("option", { name: "All teams" }) !== null).toBe(canListAllTeams); + expect(screen.getByRole("option", { name: "Workspace" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "All my teams" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Alpha" })).toBeInTheDocument(); + } + ); + + it("uses the normalized denied context grant even for an owner", () => { + teamMocks.roleKey = "owner"; + teamMocks.canListAllTeams = false; + mockPermissions.add("sessions.manage"); + render(); + fireEvent.keyDown(screen.getByRole("combobox", { name: "Team" }), { key: "Enter" }); + expect(screen.queryByRole("option", { name: "All teams" })).not.toBeInTheDocument(); + }); + + it("withdraws the All teams filter option after a server revocation", () => { + teamMocks.canListAllTeams = true; + const { rerender } = render(); + fireEvent.keyDown(screen.getByRole("combobox", { name: "Team" }), { key: "Enter" }); + expect(screen.getByRole("option", { name: "All teams" })).toBeInTheDocument(); + teamMocks.canListAllTeams = false; + rerender(); + expect(screen.queryByRole("option", { name: "All teams" })).not.toBeInTheDocument(); + }); + + it("does not fetch a fallback context before active teams load or when they fail", () => { + teamMocks.loading = true; + const { rerender } = render(); + expect(lastOptions()).toEqual({ enabled: false }); + teamMocks.loading = false; + teamMocks.error = new Error("teams failed"); + rerender(); + expect(lastOptions()).toEqual({ enabled: false }); + expect(screen.getByRole("alert")).toHaveTextContent("Couldn't load teams."); + }); + + it("clears other filters without widening the active team context", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams( + "q=login&ownerFilter=participating&visibility=private" + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?teamIds%5B%5D=team_alpha", { + scroll: false, + }); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx new file mode 100644 index 0000000000..b1e4da8657 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { MAX_SESSION_LIST_SEARCH_LENGTH } from "@open-inspect/shared/session-list-query"; +import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; +import { SessionDiscoveryFilters } from "@/components/session-discovery-filters"; +import { SessionDiscoveryResults } from "@/components/session-discovery-results"; +import { Button } from "@/components/ui/button"; +import { ErrorBanner } from "@/components/ui/error-banner"; +import { Input } from "@/components/ui/input"; +import { PlusIcon, SearchIcon, XIcon } from "@/components/ui/icons"; +import { useAuthSession } from "@/lib/auth-session"; +import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useActiveTeam } from "@/hooks/use-active-team"; +import { useEnvironments } from "@/hooks/use-environments"; +import { useRepos } from "@/hooks/use-repos"; +import { useSessionDiscovery } from "@/hooks/use-session-discovery"; +import { + buildSessionsHref, + DEFAULT_SESSION_DISCOVERY_QUERY, + hasSessionDiscoveryFilters, + parseSessionDiscoveryQuery, + type SessionDiscoveryQuery, +} from "@/lib/session-discovery"; +import { SEARCH_DEBOUNCE_MS } from "@/lib/search"; + +const MANAGE_ARCHIVED_SESSIONS_HREF = "/settings?tab=data-controls"; +/** Varied title widths so the placeholder rows read as a list rather than a block. */ +const SESSION_SKELETON_TITLE_WIDTHS = [ + "w-2/3", + "w-1/2", + "w-3/4", + "w-2/5", + "w-3/5", + "w-1/2", + "w-2/3", + "w-2/5", +]; + +/** + * Mirrors `SessionDiscoveryResults` rows so loading doesn't shift the list. Hidden from + * assistive tech because the page's always-mounted status line announces loading. + */ +function SessionResultsSkeleton() { + return ( +