Skip to content

chore: sync upstream 2026-10-02 (team-owned automations/environments) - #110

Merged
rhlsthrm merged 19 commits into
mainfrom
sync/upstream-2026-10-02
Oct 2, 2026
Merged

rhlsthrm merged 19 commits into
mainfrom
sync/upstream-2026-10-02

Conversation

@rhlsthrm

@rhlsthrm rhlsthrm commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Syncs upstream ColeMurray/background-agents f1cf0697..b8c9a80b (16 commits, 328 files) into the fork.

Five of the incoming commits are our own upstream PRs landing: ColeMurray#2192, ColeMurray#2189, ColeMurray#2124, ColeMurray#2121, ColeMurray#2123. Their files are byte-identical to upstream after the merge. The one exception is routes/teams.ts, where the fork's PAT viewer line remains.

Conflicts

  • docs/GETTING_STARTED.md: kept upstream's table (now with modal-vm) and added back the fork's four reviewer-App rows.
  • Route fixtures: arithmetic. Upstream 207/156 plus the fork's +13/+11 gives 220/167. The regenerated snapshot is upstream's 207 identities plus exactly the 13 fork routes, and no upstream route is missing.
  • route-admission.ts, automation-crud.ts, skills.ts: took upstream's evaluateOwnedResourceAdmission / authorizeSessionTarget refactor. An access token gets its owner's ctx.authorization, so the owner-scoped viewer covers the fork's old isSelfActingPrincipal ownership gate.
  • Additive test conflicts (automations.test.ts, automation-store.test.ts, scheduler.test.ts): kept both sides.

Fork-feature adaptations

Gates

typecheck 0; lint 0. Control-plane unit 6252 and integration 1955 pass after the two fixes above. web, github-bot, slack-bot, linear-bot, shared, mcp-server, sandbox-runtime node, SQL portability, modal-infra, and terraform test all pass. sandbox-runtime pytest passed 1402. It needs --basetemp=/tmp/... on macOS, because the new upstream test_docker_service.py hits the 104-byte AF_UNIX path limit under the default $TMPDIR.

No new D1 migrations upstream; high-water stays 0083, fork at 0090–0093.

ColeMurray and others added 17 commits October 1, 2026 00:32
…ray#2178)

## Summary
Implements COL-226. Modal snapshot restores no longer get a git token
minted by Modal. Every Modal launch path now uses the control-plane
credential broker, and Modal holds no source-control credentials.

- **The control plane is the only source of VCS identity.**
`createModalProviderFromEnv` passes `SCM_PROVIDER` into
`ModalSandboxProvider`, which resolves `scmCloneIdentity()` once, the
same way the Daytona, E2B, Vercel, and OpenComputer providers do.
`ModalClient` sends `clone_host` / `clone_username` on create, restore,
and image-build requests. The session lifecycle, launch config, and
provider interfaces are unchanged.
- **Modal requires the identity and has no fallback.**
`api-create-sandbox`, `api-restore-sandbox`, and
`api-create-build-sandbox` reject requests without `clone_host` /
`clone_username`. `inject_vcs_env_vars` no longer reads `SCM_PROVIDER`,
and the GitHub/GitLab/Bitbucket defaults are removed.
- **Modal-side minting is removed.** This deletes `clone_token.py`, the
`sandbox_runtime.auth.github_app` module and its PyJWT dependency, the
`github-app` secret binding on `api_restore_sandbox`, Terraform
provisioning of that secret, and the generated `GITHUB_TOKEN` /
`GITHUB_APP_TOKEN` / `OI_GITHUB_TOKEN_IS_FALLBACK` aliases on restore.
`_gh_wrapper_should_mint` now only checks for a user-supplied `GH_TOKEN`
/ `GITHUB_TOKEN`.
- **Image-build clone auth is simpler.** `ImageBuildCloneAuth` now
carries only the token. The Modal provider supplies host and username
from its own identity.
- **Docs are consolidated** into one CHANGELOG entry plus updated
statements in HOW_IT_WORKS, the control-plane, github-bot, and
modal-infra READMEs, and the security and sandbox-environment pages.

Image builds still receive a one-shot `VCS_CLONE_TOKEN` because they
have no session to broker through. User-supplied token overrides are
preserved.

## Verification
- Control-plane unit tests: 5,655 passed. Integration tests: 1,646
passed, 1 skipped.
- Modal-infra pytest: 541 passed. Sandbox-runtime pytest: 1,401 passed,
3 skipped.
- Docs tests: 44 passed. The production docs build passed.
- `npm run typecheck`, ESLint, Prettier, and Ruff check/format all
passed.
- Not exercised: a live Modal deploy, or restoring a real production
snapshot.

## Rollout
- The new Modal endpoints reject requests that lack `clone_host` /
`clone_username`. Terraform deploys Modal before the control-plane
Worker, so session launches and image builds return 400 for the short
window between the two deploys. We accept that window instead of
carrying a compatibility fallback.
- Terraform no longer provisions Modal's `github-app` secret but does
not delete an existing one. Delete it from Modal after the upgrade.
Rolling back to a version that binds it requires recreating it.
- Keep the GitHub App credentials configured for the control plane and
the GitHub bot.
- Team-scoped credential minting (COL-205) is unaffected. This PR only
moves the non-secret host/username, and tokens are still minted per
request by the broker.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/d7e84d56dd72df3752b1971396368869)*


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Changed**
* Snapshot-restored sessions now obtain short-lived Git credentials
through the control plane, consistent with fresh and prebuilt sessions.
* Sandbox launches and restores now use the configured source-control
host and clone username. One-shot image builds continue to use a clone
token.
* Modal no longer needs GitHub App credentials or the related secret.
Existing GitHub App secrets can be deleted after upgrading.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
## Summary

Implements repository-grant management, team-scoped repository catalogs,
and repository authorization at the specified write sites.

Issue: https://linear.app/colemurray/issue/COL-204

- Add installation-wide or named repository grants, with SCM identity
validation, mutually exclusive grant kinds, and an atomic 500-repository
cap.
- Batch actual grant mutations with `teams.grants_version` increments
and `team.grant_added` / `team.grant_removed` domain audit rows.
Duplicate PUTs are idempotent; existing resource references are never
rewritten.
- Filter `/repos?teamId=` after reading the existing global
`repos:list:v3` cache. Workspace and actorless calls without a team keep
the full catalog. Missing and inaccessible teams return the identical
404.
- Share identity-bearing target/grant checks across session creation,
sandbox child creation, session moves, environment repository edits,
automation selections, skill assignments/import sources, repository
secrets, image-build triggers, and environment secret import.
- Workspace repository surfaces retain existing permissions when no team
grants the repository. Otherwise, callers need membership in any
granting team, lead membership for repository secrets, or a built-in
Owner/Administrator role. Installation grants count for every
repository; these rules do not depend on enforcement mode.
- Manual team-owned environment builds require membership in the owning
team or Owner/Administrator status, and still require the team's
repository coverage. Secret import checks both destination coverage and
source secret authorization before copying or scheduling.
- Add the capability-gated Repositories tab and BFF routes. Members read
grants; leads and administrators add/remove them. Empty or failed team
catalogs never implicitly select “No repository”; the missing-grant
error names the repository.
- Audit missing-team and nonmember team 404s through
`authorizationDenial` with `team_not_visible`, retaining the identical
`Team not found` response. Allowed-decision auditing is unchanged by
this PR.

## Checkpoint Report

### Commands And Results

Validation was run sequentially with one Vitest worker, then repeated
after rebasing onto `main` at `70b8ca4`:

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed; also rebuilt by the
final root typecheck |
| `npm run typecheck` | Passed across every workspace, including
control-plane Node and integration types |
| `npm run lint:fix` | Passed |
| `npm run lint:sql-portability` | Passed: `SQL portability: clean (24
baselined occurrence(s) across 4 file(s)).` |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 345
files passed; 5,846 tests passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | 128 files passed; 1,654 tests passed, 1 skipped; shell
timeout 900000 ms |
| `npm test -w @open-inspect/web -- --maxWorkers=1` | 263 files passed;
2,630 tests passed |
| `npm test -w @open-inspect/shared -- --maxWorkers=1` | 64 files
passed; 1,074 tests passed |
| `git diff --name-only -z origin/main...HEAD -- "*.ts" "*.tsx" "*.md"
\| xargs -0 npx prettier --check` | Passed |
| `git diff --check origin/main...HEAD` | Passed |

The passing integration suite emitted workerd eviction/invalid-request
diagnostics and NDJSON warnings; the web suite emitted jsdom navigation
and timeout warnings. Neither suite reported test failures or Vitest
unhandled errors.

Targeted regression runs also passed: grant storage/workspace
authorization/image routes (168 tests), source-import
authorization/environment secrets (146 tests), and the regenerated
route/admission snapshots plus grant integration tests (28 tests).

### Red Tests And Resolved Failures

Tests were added before the grant storage/API implementations. Initial
failures included these verbatim excerpts:

```text
TypeError: store.add is not a function
TypeError: store.listTeamsForRepository is not a function
Error: The property "listTeamsForRepository" is not defined on the object.
```

The repository-owner lookup red run reported:

```text
Test Files  2 failed (2)
     Tests  24 failed | 17 passed (41)
```

After adding the lookup but before replacing the old mode-dependent
authorization, the behavioral red run reported:

```text
AssertionError: expected undefined to be 403 // Object.is equality
Test Files  1 failed | 1 passed (2)
     Tests  12 failed | 29 passed (41)
```

Intermediate validation failures were resolved rather than suppressed:

```text
AssertionError: expected 409 to be 200 // Object.is equality
AssertionError: expected 500 to be 201 // Object.is equality
AssertionError: expected [] to deep equally contain { Object (action) }
```

The first two exposed outdated fixtures: automation rows omitted their
required null `owner_team_id`, and the team-owned sandbox-child fixture
had neither a grant nor a configured SCM resolution. Fixtures now
accurately represent workspace ownership or granted team ownership. The
third exposed the missing denial audit decision and is covered by the
minimal admission fix.

Initial type/lint failures also included:

```text
Property 'name' is missing in type '{ readonly id: "role_builtin_owner"; readonly key: "owner"; }' but required in type '{ id: string; key: "owner" | "administrator" | "member" | "viewer" | null; name: string; }'.
error  `import()` type annotations are forbidden  @typescript-eslint/consistent-type-imports
```

These were corrected with complete authorization fixtures and normal
type imports. The grant-read policy's `auditAllowed: false` spread
required narrowing `requireTeam`'s return type to its actual
`active-user` variant; its runtime behavior was not changed by that type
correction.

### Verified Facts And Drift

- No schema migration: the existing D1 migration `0083` and DO migration
`56` provide the needed fields. `listForTeam` retains its numeric-ID
projection; null repository IDs require an installation grant.
- The global repository cache remains `repos:list:v3`. Scope filtering
applies to both cached and freshly fetched results, never to the cached
installation catalog itself.
- The supplied session-create pointers had moved to
`session-create.ts:104–108` and `185–198` on the initial checkout; the
existing separate grant check was moved into the common target helper
without changing creation ownership or visibility semantics.
- The listed grant URLs introduce two distinct paths, not one. The
verified catalog is 205 routes and 156 paths, rather than 205/155;
policy tests and both snapshots reflect the actual routes.
- `enforceTeamRequirement`'s missing-team and nonmember branches
returned only a response, so no denial decision reached the audit
writer. They now use the existing denial mechanism and the same reason
code/body.
- Automation/environment stores already carry `owner_team_id`, but their
creation APIs still write null ownership. Checks use persisted ownership
on updates; no ownership API was pulled forward.
- Rebased over the merged quiet-team-read/subscribe-capability and
composer/scope-refresh changes in ColeMurray#2174 and ColeMurray#2176. Their implementations
remain intact. Conflict resolution preserved the new `requireTeam`
options/defaults, both changelog entries, and regenerated the route
snapshots from the combined implementation.

### Deliberately Excluded

- No installation-token mint/cache changes, provider scope arguments,
Modal restore changes, or App-key removal. Sandbox credentials are not
narrowed or revoked by this PR; scoped credentials remain a separate
change.
- No automation/environment ownership APIs or scheduler
ownership/execution guards, team secrets, or bot bindings.
- No independent composer-page, active-team, scope-purge, allowed-read
auditing, or member-email visibility implementation. Merged follow-ups
are inherited rather than reimplemented.
- Existing workspace-level skill catalog lifecycle and background
save-hook admission remain unchanged. Repository prebuild disablement
remains a permission-gated cleanup operation that does not resolve or
execute an inaccessible repository.

## Browser Verification

Verified the real Next.js application with isolated browser API
fixtures, without committing fixture routes or bypassing application
authentication:

- Named grant add/remove, scope locking, removal from the installation
selector, and installation-wide grant behavior.
- Members retain read-only grant visibility; missing capabilities expose
no management controls.
- Desktop and mobile layouts, including no horizontal overflow at 390
px.

Viewport captures from `http://127.0.0.1:3131/teams/design`:

| Capture | Viewport | Uploaded Artifact |
| --- | --- | --- |
| Named grant management | 1440 × 1000 |
`5e87768770faab32555d127ef9030c3b` |
| Mobile grant management | 390 × 844 |
`b378de863da0bbdb4210eb6e225babb2` |
| Read-only member, installation grant | 390 × 844 |
`430aae856ae5bd1e9b597fb6cd56120d` |

Browser verification covers rendering/interactions with fixture-backed
APIs, not live OAuth or GitHub credentials. Backend authorization and
storage are separately exercised by the real-D1 integration suite.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/cf9a05bc1aa439a722201fa0456eb202)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Team leads and workspace Owners or Administrators can grant a team
access to all installation repositories or selected repositories, and
remove grants. Team members can view grants.
* Team-scoped repository and environment catalogs show only resources
covered by the team’s grants.
* Repository access checks apply to sessions, automations, environments,
skills, secrets, and image builds. Team-owned resources also require
appropriate team membership or leadership.
* Session target selection displays grant and selection errors and lets
you choose repositories again when a selection becomes unavailable.
  * Audit logs display repository grant additions and removals.

* **Bug Fixes**
* Secret imports verify the repository’s current identity and access
before importing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
ColeMurray#2180)

## Summary

GitHub sandbox credentials now reach only the session's own
repositories, for workspace-owned and team-owned sessions alike. This
replaces the earlier team-grant-wide policy in this PR.

- Resolve primary/member IDs from persisted D1 session membership.
Environment sessions retain their copied members; environment provenance
does not expand existing sessions or primary-only children.
- For team sessions, retain only those candidates covered by one current
`TeamRepositoryGrantStore.listForTeam` snapshot. Installation grants
retain session members, never installation-wide sandbox access.
- Resolve NULL IDs by structured owner/name from the identity-checked
cached installation catalog. Empty, unresolved, invalid or over-500
unique final scopes fail closed before cache lookup or POST. No
truncation or `all` fallback.
- Repository builds mint for that repository alone, even without team
grants. Environment builds use planned members intersected with the
environment owner's grants and refuse changed membership during
planning.
- Preserve the exact scope union and ID-set v2 cache keys, 128-scope LRU
cache, expiry rules, and per-key single-flight 401 recovery.
- Metadata/catalog calls, access checks, branch lists, skills import and
the GitHub bot's collaborator-metadata mint remain installation-wide.
GitLab retains its deployment-wide PAT limitation.
- Update `CHANGELOG.md` and `docs/GETTING_STARTED.md`; remove the
obsolete grant-wide resolver.

Closes [COL-205](https://linear.app/colemurray/issue/COL-205).

## Operator Impact

Private submodules, repository-backed private dependencies and
sibling-clone setup scripts now fail unless those repositories are
included in the session's environment. Team sessions additionally need
grants for those members. Editing an environment does not expand an
existing session's snapshot.

Legacy NULL IDs require an identity-matching cached repository catalog;
loading the repository list populates it. Grant removal changes the next
credential scope but does not revoke already-issued credentials, which
remain valid until expiry.

## Checkpoint Report

### Validation

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed; also rebuilt by
every root typecheck. |
| `npm run typecheck` | Final post-merge run passed in every workspace,
including control-plane production, Node, unit and integration projects.
|
| `npm run lint:fix` | Final run and commit hooks passed. Initial two
type-import failures are reproduced below. |
| `npm run lint:sql-portability` | Passed: 24 baselined occurrences
across 4 files, unchanged. |
| Scope factory/mint boundary TDD | Initial red: 11 failed, 10 passed.
Green after implementation: 21 passed. |
| Focused new-scope unit run | 11 files, 190 tests passed. Final cleanup
run: 7 files, 132 passed. |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | Final
post-merge run: 349 files, 5,873 tests passed. Pre-merge redesign run:
5,834 passed. |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | Redesign full suite passed before the final
concurrent-main merge: 128 files, 1,658 passed, 1 skipped; 592.00
seconds with a 1,800,000 ms timeout. |
| Post-merge focused workerd run | 8 files, 111 passed, covering scoped
tokens, SCM credentials, PR creation, environment snapshots, image
reconciliation, session access, WebSockets and team-member privacy. |
| Prettier, `git diff --check`, commit hooks | Passed. |

Post-merge integration command:

```bash
npm run test:integration -w @open-inspect/control-plane -- test/integration/scoped-installation-token.test.ts test/integration/scm-credentials.test.ts test/integration/create-pr.test.ts test/integration/session-from-environment.test.ts test/integration/image-build-scheduler.test.ts test/integration/session-access-routes.test.ts test/integration/websocket-session-access.test.ts test/integration/team-member-privacy.test.ts --maxWorkers=1
```

No live credentials were used. Real D1/KV tests inspect mocked mint
bodies for session isolation, grant removal, installation-grant
narrowing, NULL-member/scalar-fallback resolution and repository-only
builds.

<details>
<summary>Development failures, verbatim excerpts</summary>

Initial boundary red:

```text
TypeError: repositoryCredentialScope is not a function

 Test Files  2 failed (2)
      Tests  11 failed | 10 passed (21)
```

The first focused workerd run failed despite passing assertions because
the immediately rejected nested scope promise was reported unhandled.
Explicitly awaiting scope resolution fixed it; the subsequent
scoped-file and full runs passed cleanly.

```text
SourceControlProviderError: Cannot generate credentials: no repositories in scope
 ❯ repositoryCredentialScope src/source-control/credential-scope.ts:16:11
 ❯ resolveRepositoryCredentialScope src/source-control/repository-scope.ts:45:10
 ❯ resolveSessionCredentialScope src/source-control/session-scope.ts:23:10
 ❯ test/integration/scoped-installation-token.test.ts:246:5

 Test Files  7 passed (7)
      Tests  109 passed (109)
     Errors  1 error
```

Initial lint:

```text
/workspace/background-agents/packages/control-plane/src/image-builds/planner.test.ts
  43:35  error  `import()` type annotations are forbidden  @typescript-eslint/consistent-type-imports

/workspace/background-agents/packages/control-plane/src/image-builds/scheduler.test.ts
  19:35  error  `import()` type annotations are forbidden  @typescript-eslint/consistent-type-imports

2 problems (2 errors, 0 warnings)
```

</details>

### Main Verification and Drift

- Integrated `main` first at `ef4f737`, then at `eb50181` after it
advanced during validation, and most recently at `f1cf069` (`d4306fe`),
which brought in ColeMurray#2178. Both changelog sides and the newer
authorization/directory behavior are retained; the published branch was
not rewritten.
- The newer main removed ownership-move APIs and their repository
reader. This PR does not restore those mutations: a focused
`SessionRepositoryStore` reads ordered membership, and the credential
resolver uses the already-loaded scalar identity only as the legacy
fallback.
- D1 has no scalar `repo_id`; member rows carry IDs. Environment members
are copied on session creation, and `environment_id` is provenance
rather than credential authority.
- The shared reader preserves the existing `repos:list:v3` cache key,
payload schema and SCM fingerprint. Refreshes, writes, freshness,
metadata enrichment and metadata routing are unchanged.
- The 500-ID limit applies to the final filtered, de-duplicated scope,
not total team grants. Repository builds no longer enumerate teams; each
nonempty team credential resolution reads grants once and filters
candidates in memory.
- No schema or grant-write change is added. Credential narrowing remains
independent of `TEAMS_ENFORCEMENT`; the merged access-policy changes are
retained. Schema assumptions remain D1 0083 and DO 56.

### Deliberately Left Out

- Modal restore-token PR ColeMurray#2178 has since merged (`f1cf069`). Restored
sandboxes now fetch credentials through the control-plane broker
(`ScmCredentialsService`), so they receive the same session-scoped
tokens as fresh sessions without further changes here.
- Grant APIs/UI and writes remain separate; integration tests seed
grants directly in D1.
- Image-build secret folding is untouched.
- The owner-run GitHub live gate is still pending. Immediate revocation
and cross-isolate cache deletion/write fencing are not introduced.

All earlier prebuild, whole-team size-limit and all-team fan-out review
threads have been answered and resolved under the repository-only
policy.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* GitHub sandbox credentials are limited to repositories included in
each session; team-owned sessions are further restricted to repositories
with current team grants.
* Pull request operations and repository or environment image builds use
credentials scoped to the relevant repositories.
* Sessions with empty, unresolved, or oversized repository scopes do not
receive credentials, and access is not broadened as a fallback.
* Updated guidance explains repository access, installation-wide
metadata and catalog operations, GitLab credentials, and legacy
sessions.

* **Bug Fixes**
* GitHub provider requests retry once with refreshed credentials after
an authorization failure.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Grant Snapshot Review Follow-Up

`b21e81e` replaces repeated coverage queries with one fresh
`listForTeam` snapshot per nonempty team credential resolution.
Installation grants and fail-closed empty/oversized scopes retain their
existing behavior; grants are not cached across resolutions.

A regression first reproduced mixed-snapshot selection (expected
repository 12, received 30); it now passes and asserts exactly one grant
read. Local validation passed: 6 focused unit files / 118 tests; 3 D1/KV
integration files / 26 tests; all workspace typechecks; changed-file
ESLint and Prettier; diff checks and commit hooks.

## Code Review Follow-Up

Net change against `b21e81e` (`7988327` + `7b2e28c`), with no behavior
change to credential scoping:

- `coveredRepositoryIds` in `db/team-repository-grants.ts` is now the
only definition of grant coverage. `TeamRepositoryGrantStore.covers` and
`resolveRepositoryCredentialScope` both use it, so credential narrowing
can't drift from the grant check.
- `getInstallationTokenCacheKey` and `reposCacheIdentity` use the shared
`sha256Hex` helper instead of hand-rolled hex encoding.
- A memory token cache hit now only refreshes the entry's LRU position.
Expired entries are still pruned when a token is inserted.

`7988327` added a fallback that refreshed the repo catalog for NULL
repository IDs. `7b2e28c` reverted it: only sessions created before
migration 0032 (2026-07-07) lack IDs, and those are outside the 30-day
support window. The fail-closed behavior described under Operator Impact
still applies.

Other review findings were left unchanged because the existing tests or
this description already make them deliberate:
- The planner skips the clone token instead of rejecting the build.
- Members without a team grant are dropped from team scopes.
- GitLab still resolves a credential scope even though its PAT ignores
it.
- PR refresh resolves the scope for each artifact.
- Membership is not read for a missing session.

Validation: control-plane typecheck (all four configs), ESLint and
Prettier; full unit suite on the final code: 349 files, 5,874 tests
passed; 6 related workerd integration files / 96 tests passed.

### Merge with `main` at `f1cf069` (`d4306fe`)

- `image-builds/planner.ts`: kept the scoped
`generateCredentialHelperAuth(tokenScope)` call and adopted ColeMurray#2178's `{
type: "credential_helper", token }` clone-auth shape; the Modal provider
now supplies host and username. Updated the matching planner test.
- `CHANGELOG.md`: kept both entries and dropped "Modal restore-token
scoping remains a separate change", since restores now use the scoped
broker.
- No new unscoped credential mints came in from `main`; the only `{
kind: "all" }` callers are the skills import paths, which are
installation-wide by design.
- Validation after the merge: control-plane typecheck (all four configs)
passed. The full unit suite (350 files, 5,906 tests) passed except for
the one planner test asserting the pre-ColeMurray#2178 clone-auth shape; it passes
after the update, and the image-build unit tests pass (321 tests). 6
related workerd integration files / 96 tests passed. CI runs the
workspace-wide typecheck.



### Test trim (`f67a506`)

Reduced the PR's test additions from about 4,300 to about 2,700 lines
with no coverage loss. Line and branch coverage is unchanged for all 22
production files the PR touches, and `session-scope.ts` branch coverage
goes from 75% to 100%.

- Composition-layer tests (`components.credentials`, `autofix/handler`,
planner, scheduler) re-ran resolver scenarios through prototype spies:
team A/B grants, revoked grants, NULL-id catalog lookups, empty and
over-500 scopes. They now only check that the resolved scope is passed
through and that failures fail closed. The resolver logic stays covered
by `repository-scope`/`session-scope`/`credential-scope` tests and the
real-D1 integration test.
- Removed tests that only replayed their own mock sequences or asserted
incidental call counts and order. Examples: "re-reads on every call",
"resolves scope again on the next delivery", and
`toHaveBeenCalledBefore` checks.
- Removed scope assertions that had been copied onto every GitHub
provider method. One focused per-call scope test and the 401-retry test
remain.
- Folded `github-app.scope.test.ts` into `github-app.cache.test.ts`. The
LRU and memory-cache tests now actually prove the entries come from
memory and are evicted in least-recently-used order. Added a direct test
of the `repository_ids` mint body.
- Integration: removed cases that repeated unit-level cache mechanics
(cold KV read, expiry limits, v1 keys, concurrent dedupe). Kept
isolation, grant narrowing, installation-grant narrowing, NULL/scalar
resolution, repository-only builds, fail-closed, and 401 recovery.

Validation: control-plane typecheck and ESLint are clean. The full unit
suite passed (349 files, 5,803 tests), and the 6 related workerd
integration files passed (88 tests).


### Test file restore (`c814e23`)

Undid the split of `pull-request-service.test.ts` into
`pull-request-service.test-support.ts` and
`pull-request-service.credentials.test.ts`. The helpers and the three
moved tests are back in their original positions. That file's diff
against `main` is now only the credential-scope additions (+31 lines):
the harness scope, the push-auth scope assertions, and the fail-closed
test. The session unit suite passes (90 files, 1,627 tests).

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
…2199)

## Summary

Reorganizes the session inspector sidebar:

- **Info is now the first tab** (order: Info, Changes, Tasks, Tools) and
is the default tab for viewers with no stored tab choice. Viewers who
already picked a tab still reopen on it.
- **Captured media moved from Changes into Info**, renamed from "Media
(n)" to **"Artifacts (n)"**. It uses the existing `CollapsibleSection`
(expanded by default, toggle exposes `aria-expanded`). It sits right
after Run information / Repository.
- The Changes tab now only shows file changes.

Opening a diff still switches the inspector to Changes via
`showTab("changes")`, so the diff flow is unchanged.

## Notes for review

- Making Info the **default** tab, not just the first in order, is my
reading of "move info to be the first tab". If Changes should stay the
default, revert the one-line change to `DEFAULT_SESSION_INSPECTOR_TAB`.
- The "Media (n)" pill in the desktop action bar and mobile actions menu
is unchanged. It's outside the sidebar.

## Testing

- `npm test -w @open-inspect/web`: 263 files / 2672 tests pass
- Web `tsc --noEmit` is clean; ESLint + Prettier pass
- Updated tests: tab order and keyboard navigation with Info first, the
Info default in the hook, the Changes-panel tests now open Changes
first, and a new test that checks Artifacts renders in Info and
collapses/expands.

## Visual verification

Checked in a local dev server using a temporary, uncommitted preview
page that renders `SessionRightSidebar` with fixture data. Viewport was
1512×982.

- Info first and selected, with Artifacts (2) expanded: screenshot
artifact `c13d29d7a5b7b08722d457e9e131887f`
- Artifacts collapsed: screenshot artifact
`040c3fcfbf1ea80f6805990012796179`
- Collapse → expand → switch to Changes (no media there) → back to Info:
video artifact `ffd194fe2a181537433d85240f7b8a30`

The media thumbnails show as blank placeholders because the preview had
no media backend. The Changes tab showed "Unable to load changes."
because the fixture had no diff.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/eee2433c00c3516de6af9e1b0550cba3)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Updates**
* The session inspector now opens to the Info tab by default, with Info
appearing before Changes in the tab order.
* Captured media appears in an expandable Artifacts section in Info
instead of the Changes panel.
  * The Changes panel focuses on checkout changes.
* Opening media from the mobile session header shows the Info tab
without changing the remembered inspector tab.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
## Summary

Implements COL-246, increment 6 of the COL-240 lifecycle-manager
refactor.

- Extract `failConnectTimeout`, `terminateStaleHeartbeat`,
`snapshotAndStopStaleSandbox`, and `stopForInactivity` into stateless
`watchdog-effects.ts` functions with per-function dependency subsets.
- Keep the complete boot-budget effect, alarm context capture, policy
dispatch, healthy/warning scheduling, failure accounting/reporting,
public policies, and the single termination flag in
`SandboxLifecycleManager`.
- Add assembled-manager ordering/interleaving coverage, extend existing
ESLint consumer boundaries, and update the repository ownership design.

## Ownership and Ordering

The effects receive narrowed storage/socket/broadcast/shutdown/access
ports and named checkpoint, failure, and generation-targeted stop
operations. They have no manager reference, flag setter, scheduler,
timer, or separate lifecycle state.

For non-held boot budget, shutdown send, generation fence, failed
state/breaker/access retirement, status/error publication and
persistence, and socket detachment all precede guard acquisition. Only
explicit provider stop runs inside the manager's `failBootBudget`
try/finally. Fatal/unresponsive termination remains in the manager;
other watchdogs intentionally do not acquire this guard.

Existing retained-source holds, unfenced no-stop late-bridge self-heal,
half-boot versus ready-workspace preservation, provider-managed
preserve-stop, detached heartbeat capture, shutdown-first inactivity,
post-await abandonment checks, log/user messages, and
`SandboxAlarmResult` meanings remain unchanged. The shared alarm
scheduler and session-handler ordering are untouched. This extraction
does not add broader generation hardening or fix the separately
documented baseline safety gaps.

## Coverage

Retains all existing assembled-manager and Workerd suites. Strengthens
the exact boot-budget failure/access/publication/persistence trace,
actual spawn exclusion during stop and successful spawn after failed
stop, retained-source no-send/no-detach and duplicate-accounting
behavior. Continuation tests use fresh row snapshots and prove that
non-identity field changes do not abandon the same generation. Adds
deferred stop coverage for independent ID/timestamp replacements with
legacy/resumable heartbeat and inactivity paths, checkpoint uncertainty
blocking competing teardown, and inactivity waiting for held/owned
shutdown before any legacy effects.

## Related Work

Starting checkout: `60930ce`, with COL-245 integrated via ColeMurray#2170 after
launch/access/VM extraction. COL-238 remains In Progress and ColeMurray#2141 is
open/unmerged; its proposed heartbeat confirmation is not imported.
Existing alarm policy and boot-phase helpers are reused unchanged.

## Verification

Node `v24.20.0`, npm `11.19.0`; dependencies were already installed.
Checks ran sequentially, with one Vitest worker to respect sandbox
resources.

| Command | Final Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle
src/session/alarm src/session/sandbox-shutdown --maxWorkers=1` | Passed:
26 files, 785 tests |
| `npm run test:integration -w @open-inspect/control-plane --
session-lifecycle-alarm-recovery sandbox-early-connect sandbox-shutdown
sandbox-state-retention --maxWorkers=1` | Passed: 4 files, 57 tests |
| `npm run typecheck -w @open-inspect/control-plane` | Passed: Worker,
Node, unit, integration configurations |
| `npm run lint -w @open-inspect/control-plane` | Passed |
| `npm run test:lint-sandbox-boundaries` | Passed: 2 tests |
| `git diff --check` | Passed; committed base diff also passed |

Touched-file formatting passed:

```bash
npx prettier --check docs/plans/sandbox-lifecycle-manager-refactor.md eslint.config.js scripts/lint-sandbox-boundaries.test.mjs packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts packages/control-plane/src/sandbox/lifecycle/manager.ts packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts
```

An initial targeted run failed two new assertions because its fixture
stubs startup decisions as unmanaged despite real checkpoint ownership.
The new tests were narrowed to competing teardown; existing
real-coordinator tests retain startup-hold coverage. The corrected
targeted run passed 79 tests. Initial typecheck found an overly narrow
mock return type, corrected to the existing `StopResult`; the focused
unit suite and typecheck were rerun successfully. No remaining
verification blockers or production behavior fixes are claimed.

Full-package/full-story tests and bundle builds remain COL-247's scope.
Workerd uses provider substitutes; no deployment or live-provider
verification was performed.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/d1b90cca7d20fef75c0f94573635dada)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved sandbox lifecycle reliability during connection-timeout,
stale-heartbeat, and inactivity handling. If a sandbox is replaced while
shutdown is underway, the replacement remains unaffected, and shutdown
already owned by another operation is not repeated.
* Improved handling of uncertain checkpoint outcomes so competing alarm,
failure-reporting, and teardown actions do not proceed.
* **Refactor**
* Updated internal lifecycle handling while preserving existing timeout,
shutdown, and recovery behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
## Summary

Addresses COL-234 with documentation-only changes.

- Correct the build-allocation recovery claim: a lost create response
marks the build failed, and its VM remains until the provider timeout
(40 minutes by default, up to 70 minutes including finalization grace).
A re-triggered build uses a new build ID and allocation name.
- Add `modal-vm` to the pre-built image provider list and explain that
it builds Modal images on the VM backend, separately from `modal`
images.
- Add a linked `modal-vm` entry to the `SANDBOX_PROVIDER`
deployment-settings row, preserving Prettier's table alignment.
- Require a web redeployment after changing `sandbox_provider` with
Terraform. For Vercel, operators must manually run **Deploy Web** after
the apply so the build-time provider value and Pre-Built Images
filtering are updated. This applies to every provider switch.

## Verification

- Checked the documentation against the current build workflow, Modal
client and build lifecycle, provider registry, Terraform configuration,
Deploy Web workflow, and image-builds API filtering.
- `npx prettier --check docs/MODAL_DOCKER.md docs/IMAGE_PREBUILD.md
docs/GETTING_STARTED.md` passes.
- `git diff --check origin/main...HEAD` passes.
- Only `docs/MODAL_DOCKER.md`, `docs/IMAGE_PREBUILD.md`, and
`docs/GETTING_STARTED.md` changed. No application code changes.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/9f7eb3c5e11650155e8b1e71abd13700)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Added `modal-vm` as a supported sandbox provider in the configuration
and getting-started guides.
* Clarified that `modal-vm` sessions use images built on the VM backend,
not images built under `modal`.
* Updated build-retry guidance: after a lost create response, the VM may
remain running until its provider timeout; retries use a new build and
allocation.
* Clarified provider-switch deployment steps for Cloudflare and Vercel,
including that Vercel’s image-build list may show the previous provider
until a new production deployment is live.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
…ray#2204)

## Summary

The team page had five dropdowns rendered as native `<select>` elements,
so they used the browser's default dropdown instead of our design
system. They now use the Radix-based `@/components/ui/select` component,
which other team controls like the "Add member" picker already use.

| Component | Dropdown |
| --- | --- |
| `settings/team-detail.tsx` | Join policy, Default visibility |
| `settings/team-members-table.tsx` | Member role (compact density to
fit the table row) |
| `teams/team-repositories.tsx` | Grant scope, Repository |

The behavior stays the same:
- Labels are still linked to the triggers through `id`/`htmlFor` or
`aria-label`, so the accessible names don't change.
- Disabled states and the per-option disabling for grant scope are
preserved.
- The repository dropdown uses `SelectValue`'s placeholder instead of an
empty `<option>`.

## Tests

- Changed tests that used `fireEvent.change` on native selects to open
the dropdown and pick an option with `userEvent`.
- When grants exist, the grant scope dropdown is disabled, so its
options can't be opened. Those tests now check that the trigger is
disabled and shows the right value, instead of checking options in a
hidden native list.
- Added a test that picks a join policy and a default visibility from
the dropdowns and checks the PATCH payload.

`npm test -w @open-inspect/web` (263 files, 2674 tests), `tsc --noEmit`,
and eslint all pass.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/c9e4de358d8ac55b88cf4d0b2af19633)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **UI Improvements**
* Updated team settings, member-role controls, and repository grant
selectors to use consistent dropdowns.
* Existing options, permissions, saving states, and grant-scope
restrictions are preserved.
* Repository selection continues to show available repositories and
disables controls during loading or errors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
…urray#2203)

## Summary

Closes the local audit/verification work for
[COL-247](https://linear.app/colemurray/issue/COL-247), final increment
of [COL-240](https://linear.app/colemurray/issue/COL-240). All six
prerequisite implementations were verified in the actual checkout, not
inferred from issue status. Parent design is updated; review/merge and
normal release remain required.

Audited combined changes from research baseline
`eef911f36e704fc49104546a9cd52b584d8b9223` through starting
`b98a378bdbe2dba1237953da9162e8a85a7e926a`. Tested the source/test
contents committed as `3b9e7a0` using Node `v24.20.0`, installed
dependencies, and sequential checks from repository root.

## Files and Evidence

- `eslint.config.js` and `scripts/lint-sandbox-boundaries.test.mjs`:
extend existing import enforcement to launch-context/startup-errors,
including extension-bearing imports. No custom scanner or rule
weakening.
- `src/session/components.ts`: explicitly justify the composition-only
launch integration-port import; runtime wiring unchanged.
- `test/integration/sandbox-vm-reconciliation.test.ts`: three
production-composed Workerd regressions with real SQL/encryption,
foreground pending reservation and reconstructed runtime. Independent
expected-reference/timestamp changes during encryption refuse commit
without mutation/adoption/publication. Success preserves early
readiness, conservative lifetime, encrypted access and distinct
admission/acknowledgement/foreground gates, without reconstructing
terminal signing authority.
- `test/integration/sandbox-state-retention.test.ts`: run reconstructed
rejected cleanup through production scheduled delivery, both
shutdown-priority passes, retry-before-I/O, delivery acknowledgement and
duplicate wake-ups under a durable hold. Assert observations outside
intentionally caught provider errors.
- `src/sandbox/lifecycle/alarm-effects.test.ts`: two controlled
assembled reproducers explicitly characterize inherited unsafe
boundaries rather than quietly fixing them.
- `docs/plans/sandbox-lifecycle-manager-refactor.md` and new
`sandbox-lifecycle-refactor-verification.md`: actual ownership, combined
history, validation results, related-work reconciliation, deviations,
separate follow-ups and release limits.

Control-plane source/test paths above are relative to
`packages/control-plane/`.

## Actual Ownership

| State / Responsibility | Owner |
| --- | --- |
| `isSpawningSandbox`, `isTerminatingSandbox`, `providerStartupPending`,
lazy logger | Manager |
| All five bridge/auth fields | `VmStartupReconciliation` |
| Launch inputs/settings/images | Readonly `SandboxLaunchContext` |
| Access signing/reuse/artifacts/retirement/notifications |
`SandboxAccess`, with no retained signing key |
| Rejected cleanup / local bounded stop | Stateless
cleanup/provider-stop functions |
| Connect/heartbeat/snapshot/inactivity effects | Stateless watchdog
functions |
| Entire boot-budget effect, termination guard, fatal/unresponsive
policy, claims/rejection/breaker/admission/recovery | Manager |
| Conditional SQL/encryption | `SandboxRepository` |
| Durable checkpoint/shutdown/holds/receipts/continuation/recovery |
Shutdown coordinator and repository |
| Shared pending/in-flight deadlines | Existing alarm scheduler |

One public lifecycle authority and one durable shutdown owner remain.
Access precedes shutdown/manager construction; shutdown calls focused
access retirement directly, with no manager callback cycle. Constructors
do not execute runtime work. No broad manager/context dependency or
universal mutex was added.

## Compatibility

T7 changes no lifecycle/provider/repository runtime implementation,
public consumer return contract, stored/wire shape, schema, backend
protocol, settings, timeout or retry. Earlier constructor/internal type
changes remain internal composition details.

The combined series includes two explicit earlier reviewed fixes:
COL-244 suppresses notification after refused deferred acceptance;
COL-245 requires an actually dispatched successful stop for retirement
confirmation. These are documented exceptions to a purely mechanical
extraction claim, not new T7 fixes.

Separately landed held-save retry, Modal Docker/tunnel/save/termination,
COL-226 restore credentials, model catalog and identity/team
secret/grant/scoped-token work are preserved. COL-130/151/155/156/159
remain separate; COL-161/238 remain in progress. Heartbeat PR ColeMurray#2141 is
still open/unmerged and was not imported.

## Validation

All expensive checks ran sequentially with one Vitest worker. No
required command remains blocked.

| Exact Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Exit 0 |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | Exit 0:
359 files / 6,114 tests |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | Terminal timeout at 600 s; not counted as a pass |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1 --reporter=verbose` | Exit 0: 137 files / 1,742 passed /
1 existing skip. Entire final test version rerun successfully in 647.39
s |
| `npm run typecheck -w @open-inspect/control-plane` | Exit 0: Worker,
Node, unit and integration configs; rerun after final test refinement |
| `npm run build -w @open-inspect/control-plane` | Exit 0: Worker and
Node bundles; esbuild size warnings only |
| `npm run lint -w @open-inspect/control-plane` | Exit 0: no
warnings/errors |
| `npm run test:lint-sandbox-boundaries` | Exit 0: 2 tests |
| `npm run format:check` | Exit 0: entire repository, including
committed tree |
| `git diff --check` | Exit 0; staged check also passed |

Additional ESLint check covers the edited config/scripts/integration
tests omitted by `eslint src/`; it passed. Commit hooks ran normally.
Focused commands and their exact results are in the repository report,
including the initial fixture-only failure caused by retaining a recent
predecessor spawn timestamp. Only that test timestamp was corrected. No
baseline application suite failure was found.

The existing Workerd skip is Cloudflare KV TTL expiry because its
conformance fixture has no controllable clock. No lifecycle check was
newly skipped. Workerd deliberately exercises eviction failures and
provider substitutes; this is not live-provider evidence.

## Separate Follow-ups and Limits

The added characterization tests reproduce baseline gaps without
authorizing a behavioral redesign:

- An unmanaged shutdown-ownership await permits successor access/status
retirement; an absent captured handle can retarget the successor using
the old timestamp. Separate follow-up: generation/ownership revalidation
and explicit-target stop semantics preserving absence.
- Old connect-timeout completion can persist/publish failure on a ready
successor. Separate follow-up: generation-scoped error
persistence/publication.

Existing gaps remain documented: unguarded fresh/restore access writes,
prior retirement handle clearing, partial access
retirement/secret-read/bridge acceptance boundaries, separate
pending-record writes, warning scheduling and final attachment status
rechecks.

Evidence is not exhaustive: combined second-reservation/hash
authentication, outstanding old bridge during actual prebuilt retry,
full Node lifecycle transport, and every Workerd watchdog-to-queue
combination remain limits. Existing assembled/real-storage/Node
conformance tests are retained, not replaced with collaborator mocks.

## Release and Rollback

No schema migration, feature flag or deployment. No live-provider
canary, exactly-once, remote cancellation or guaranteed recovery claim.
Normal review/merge/release remains required. Rollback must revert a
coherent reviewed increment with dependent increments and independently
landed fixes accounted for, not an arbitrary old binary or destructive
reset.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/d99e1cddda5aed8e11b2f815a355b483)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated lifecycle ownership and verification records with validation
results, test coverage, known gaps, evidence limits, and release and
rollback considerations.
* **Tests**
* Expanded coverage for VM startup reconciliation, replacement
sandboxes, connection timeouts, and cleanup retries through scheduled
deadlines.
* **Maintenance**
  * Added checks restricting imports of internal lifecycle modules.

No runtime or user-facing behavior changes are included.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
## Summary

This is part 1 of 2, split out of ColeMurray#2188. The automations half is stacked
on this branch. It is rebased onto current `main`, including ColeMurray#2181 (team
repository grants) and ColeMurray#2180 (installation token scoping).

- **Ownership on create.** Environments can be created with a `teamId`.
Creation honors the require-team setting and refuses archived teams, and
creating a team environment requires lead authority plus
`environments.manage`. Names are unique within each team or workspace
scope; the unique-index race is answered with 409.
- **Owned-resource admission.** A new `environment` route requirement
(`requireEnvironment` / `environmentRequirement`) is evaluated by
`authorization/owned-resource-admission.ts`.
- Team environments are hidden from non-members with the same 404 as a
missing environment.
- Visible denials return 403 with `environment_action_denied` and a
`reason_code`.
- Secrets, settings, and image-trigger routes require the feature
permission *and* access to the owning environment.
- Workspace-owned environments stay permission-based, including
`environments.manage` for custom roles.
- **Session targets.** A new `authorizeEnvironmentTarget` (alongside the
unchanged `authorizeSessionTarget` permission/grant preflight) requires
`use` access and binds the environment to the destination `ownerTeamId`.
A team environment can only be used by sessions owned by that team,
including child sessions spawned from a parent (checked before settings
resolution or child admission). Sandbox clone inheritance tolerates
dangling environment provenance.
- **Lists and capabilities.** `GET /environments` returns only readable
rows, with `ownerTeamId` and `capabilities`
(`canRead`/`canManage`/`canUse`). A new `?ownerTeamId=<team|null>`
filter selects exact ownership. The mixed image-status feed omits
unreadable environment scopes.
- **Web.**
- The environment form gets a team selector at creation, and ownership
is read-only when editing.
  - Team pages get an Environments tab.
  - Row actions are gated on server capabilities.
  - `useEnvironments` now takes `{ teamId, ownerTeamId }`.
  - The BFF strips ownership fields from PUT bodies.

## Decisions made while rebasing onto `main` (differences from ColeMurray#2188)

- **`?teamId=` keeps `main`'s meaning.** `main` already used it as the
team session catalog (repositories fully granted to the team), so
ColeMurray#2188's ownership filter moved to `?ownerTeamId=`. The team catalog now
also excludes environments owned by *other* teams, since launching with
them is rejected with `environment_team_mismatch`.
- **Grant checks use `main`'s helpers.** ColeMurray#2188's duplicate checks
(`validateTeamRepositories` and the separate secret-import `covers`
check) were dropped in favor of `authorizeSessionTarget` and
`authorizeTeamRepositories`. Grant errors therefore use `main`'s body,
`target_team_missing_grant` without `reason_code`.
- **Scalar edits follow `main`.** They don't re-check grants unless
prebuilds stay enabled on a team environment, so disabling prebuilds on
an environment with revoked grants still works.
- **Smaller cleanups.**
- The environment image trigger reuses the admitted environment; its old
`not_member` branch could no longer be reached.
  - The list handler reuses the request's membership snapshot.
- **Review follow-ups (consolidation).**
- Environment access rules live entirely in shared
`checkEnvironmentAccess`. Workspace `manage` is encoded in
`ENVIRONMENT_RULES`, and unbound services see only workspace
environments, so the control plane has no extra exceptions.
- `evaluateEnvironmentAdmission` returns the admitted environment
instead of writing `ctx`. Only route admission stores
`ctx.environmentAdmission`, and handlers read it through
`admittedEnvironment(ctx)`, which throws if the route didn't admit one.
- New `routes/team-ownership.ts` provides `resolveCreationOwnerTeam`
(used by environment and session create) and `admitTeamCatalog` (used by
the `?teamId=` catalogs of `/repos` and `/environments`). This removes
copies that `main` already had. Session create's `team_archived`
response now also includes `reason_code`.
- Environment create and update share `resolveAuthorizedRepositories`.
The prebuild grant re-check is `authorizeStoredTeamRepositories`. Store
parameters are named `ownerTeamId`, and `getByName` requires an explicit
scope.
  - Shared `teamIdSchema` replaces the duplicated regex.
- Spawn-child runs only the environment permission check and the
ownership check early. The repository preflight is back in `main`'s
position, so the parent repository comes from one source.
- Web: `environmentAccess()` combines server capabilities with feature
grants, and the edit view moved to `EnvironmentDetail`. The API proxy's
PUT allowlist is derived from `updateEnvironmentInputSchema`.
- Left as is, deliberately: `capabilities` stays optional, because the
bots re-validate their KV-cached environment lists with this schema.
`?teamId=` and the create body's `teamId` keep their wire names,
matching `main` and session create.
- **Behavior change.** Changing a *workspace* environment's secrets,
integration settings, or image builds now also requires
`environments.manage`, in addition to `environments.secrets.manage` /
`environments.settings.manage` / `environments.images.manage`. Built-in
roles are unaffected; custom roles holding only the feature permission
lose those actions.
- **Behavior change.** Actorless bots (e.g. github-bot's `GET
/environments/:id`) now get the same 404 for team-owned environments as
for missing ones, matching the list, which already hid them.
- **Interim guard until the automations PR lands.** Automations are
still workspace-owned on this branch, so automation environment
selection treats team-owned environments as missing. Without this, a
workspace automation could launch with a team's environment. The
automations PR replaces it with full team validation.
- `useResourceTeams` is included whole, `automation` mode too, because
both PRs share it.
- Test updates: `main`'s ColeMurray#2181 unit tests needed fixture updates for the
new admission (e.g. callers now lead the owning team, and `listForUser`
is loaded once by admission). Every case keeps its original intent. A
team member who isn't lead now gets `not_owner_or_lead` before
source-grant checks.

No migration: migration `0083` already has `owner_team_id` and the
per-team name index.

## Validation

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | passed |
| `npm run typecheck` | passed |
| `npm run lint` | passed |
| `npx prettier --check` (changed files) | passed |
| `npm test -w @open-inspect/shared -- --maxWorkers=1` | 1,093 passed |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 6,125
passed |
| `npm test -w @open-inspect/web -- --maxWorkers=1` | 2,699 passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | 1,779 passed, 1 skipped |

The route-catalog snapshot was regenerated. Only the 11 environment
routes changed.

Supersedes the environments half of ColeMurray#2188.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/5f8068042d7f4f3642a2c58687d5bd18)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Teams have an Environments tab for viewing and managing team-owned
environments.
* Environments can be assigned to a team when created. Names are unique
within each ownership scope, and workspace settings can require new
environments to belong to a team.
* Environment lists reflect the selected scope and show available read,
manage, and use access.
* **Bug Fixes**
* Access to environment settings, secrets, and image actions now
respects environment permissions and team membership.
* Sessions and child sessions cannot use environments owned by a
different team.
  * Workspace automations no longer accept team-owned environments.
  * Environment updates forward only supported configuration fields.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
## Summary

This is part 2 of 2, split out of ColeMurray#2188, and it is **stacked on ColeMurray#2205**
(team-owned environments). Review and merge ColeMurray#2205 first; this PR's diff
covers only the automation work. The tree of this branch is exactly
ColeMurray#2188 rebased onto current `main`, apart from the CHANGELOG wording.

- **Ownership on create.** `POST /automations` accepts a `teamId`. It
honors the require-team setting and refuses archived teams with
`team_archived`. The canonical executor must be a team member.
- **Admission.** Automation routes move onto the shared owned-resource
admission from ColeMurray#2205, extended with a `read` operation
(`requireAutomation("read")`, with actorless slack-bot read).
- Team automations are hidden from non-members with the same 404 as a
missing automation.
- Visible denials return 403 with `automation_action_denied` and a
`reason_code`.
  - Management and trigger grants stay independent of read grants.
- The executor-only check in `route-admission.ts` is replaced by the
shared evaluator.
- **Discovery.** `GET /automations` is filtered to what the viewer can
see, accepts `?teamId=` for team scope, and returns `ownerTeamId` and
`capabilities` (`canRead`/`canManage`/`canTrigger`).
- **Targets.** Selected environments must be visible and belong to the
automation's team (`environment_team_mismatch`). `use` is required only
for replaced environments.
- Edits revalidate team repository grants across all final targets
(stored repositories and every environment's repositories) through
`main`'s `authorizeTeamRepositories`.
- This replaces ColeMurray#2188's separate grant helper, so denials use `main`'s
`target_team_missing_grant` body.
- **Executor reassignment.** New `PATCH /automations/:id` lets a lead or
admin reassign the executor, including after the current executor has
left the team. It checks that the candidate is active, a member of the
team, and able to launch. The update is atomic and writes an
`automation.executor_changed` audit row.
- **Execution.**
- The scheduler and authorization guard require the executor (and manual
requesters) to stay team members, the team to be unarchived, and
repository grants to still be current.
- Run sessions inherit the automation's team and its default visibility.
- Slack follow-ups apply the persisted session's collaboration decision.
- Run history hides linked-session details from viewers without access.
- **Web.**
- Automation forms get a team selector at creation, and ownership is
read-only when editing.
  - Team pages get an Automations tab.
  - Target catalogs are scoped to the team.
- The browser-side ownership rule mirror
(`lib/automation-authorization.ts`) is deleted; server capabilities now
drive the controls.
  - Navigation identifiers are encoded.
  - New `automation-collection` and `automation-cache` helpers.

The route catalog grows by one route (`PATCH /automations/:id`): 207
routes across 156 paths. No migration.

## Changes from ColeMurray#2188 during rebase

- The interim guard from ColeMurray#2205 (team-owned environments treated as
missing for workspace automations) is replaced here by full team
validation in `resolveEnvironmentSelection`.
- Automation unit tests from `main`'s ColeMurray#2181
(`automation-update.test.ts`) needed fixtures for environment ownership
and viewer membership. No expectations changed.
- Integration tests now expect `main`'s grant-denial body. One ColeMurray#2181
test seeds team-owned environments, because they must now match the
automation's team.

## Validation

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | passed |
| `npm run typecheck` | passed |
| `npm run lint` | passed |
| `npx prettier --check` (changed files) | passed |
| `npm test -w @open-inspect/shared -- --maxWorkers=1` | 1,097 passed |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 6,160
passed |
| `npm test -w @open-inspect/web -- --maxWorkers=1` | 2,749 passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | 1,863 passed, 1 skipped |

Supersedes the automations half of ColeMurray#2188.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/5f8068042d7f4f3642a2c58687d5bd18)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automations can be owned by a team and managed from a team’s
Automations tab, with team-scoped browsing and creation.
* Team leads can reassign automation executors; changes are recorded in
the audit log.
* Automation controls reflect your access to each automation, and
team-owned automations use the team’s default session visibility.
* **Bug Fixes**
* Automation runs now hide linked session details when you don’t have
access to those sessions.
* Automation execution and Slack follow-ups check current team
membership, permissions, and repository access.
* Environment selection respects team ownership and access, while
allowing inherited environments that are no longer available.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
…ColeMurray#2192)

## Problem

`docs/AUTH.md:145-152` says that on a team-owned session, every non-read
action requires current membership in the owning team, in `off`,
`shadow`, and `on` modes. The rule names prompting and lifecycle
operations. Despite this, a parent session's sandbox can prompt or
cancel a team-owned child for an active prompt author who is not in the
owning team. That happens when an author is removed from the team while
their prompt is still processing, because membership is read at check
time.

Sandbox tokens skip the route-level session requirements.
`routing/route-admission.ts:774-780` admits a sandbox principal on
sandbox-fallback routes such as `…/children/:childId/cancel`, and
`…/children/:childId/prompt` is declared with `NO_AUTHORIZATION`
(`routes/session-children.ts:434`). That leaves the handler checks as
the only gate:

- `sandboxChildAccess` (`routes/session-children.ts:55-59`) returns
`true` for any same-team child that is `workspace`-visible, or
`team`-visible under a `team` parent. It does this for every action and
never looks at the active prompt author. `handleCancelChild` relies on
it for `lifecycle` on the child (`:326`) and on each nested descendant
(`:351`).
- `handlePromptChild` only calls `sandboxChildAccess` when the child is
`private` (`:213-217`). Every other child receives the follow-up prompt
with `author: author.data` (`:247`) and no access check. That includes a
`team` child under a `workspace` parent, which `sandboxChildAccess`
would otherwise send to the author check.

Neither handler reads `TEAMS_ENFORCEMENT`, so the result is the same in
every mode. Observed on main (`f1cf0697`) with a team-owned,
team-visible parent and child, where the active prompt author is a
workspace `member` but not in the owning team:

| `TEAMS_ENFORCEMENT` | Same user cancelling through the user route |
Parent sandbox `POST …/cancel` | Parent sandbox `POST …/prompt` |
| --- | --- | --- | --- |
| `off` | 403 | 200, child status `cancelled` | 200, message stored in
the child |
| `shadow` (default) | 403 | 200, child status `cancelled` | 200,
message stored in the child |
| `on` | 404 | 200, child status `cancelled` | 200, message stored in
the child |

## Fix

- `sandboxChildAccess`: the visibility shortcut now applies only to
reads and to teamless children. Non-read actions on a team-owned child
go through the existing active-author check (`checkSessionAccess`),
which private children already use.
- `handlePromptChild`: runs `sandboxChildAccess` for every child, as
`handleCancelChild` already does.

Parent-sandbox list and detail reads, teamless children, and private
children behave as before.

Both changes are needed. With only the `sandboxChildAccess` change,
cancel is fixed but the prompt route still delivers the prompt (see the
mutation results below).

## Verification

- New integration test in `test/integration/child-session-ops.test.ts`,
run for both `prompt` and `cancel`: "requires the parent prompt author's
current team membership to … a team child". The parent sandbox gets 404
while the author is outside the owning team, and the child stays
`active` with no messages. After the author is added to the team, the
same request returns 200.
- Before the fix (main `f1cf0697`): both cases fail with `expected 200
to be 404`.
  - After the fix: both pass.
- Mutation: reverting only the `sandboxChildAccess` change fails both
cases. Reverting only the `handlePromptChild` change fails `prompt` and
passes `cancel`.
- A throwaway (uncommitted) probe ran the same scenario through
`routeRequest` with `TEAMS_ENFORCEMENT` set to `off`, `shadow`, and
`on`. Before the fix it produced the table above. After the fix, the
sandbox got 404 for both actions in all three modes with the child
untouched, and 200 once the author was a member.
- `session-children.test.ts`: the `handlePromptChild` unit tests now
give the handler a parent-bound sandbox principal and separate teamless
child and parent rows (`ownerTeamId: null`, `visibility: "workspace"`).
The handler now runs the access check for every child, so it needs both.
- Commands, run in `packages/control-plane` on the PR head:
- `npx vitest run src/routes/session-children.test.ts
src/router.policy.test.ts src/router.scm-credentials.test.ts`: 169
passed
- `npx vitest run --config vitest.integration.config.ts
test/integration/child-session-ops.test.ts
test/integration/route-admission-matrix.test.ts
test/integration/session-access-routes.test.ts
test/integration/session-scope-routes.test.ts
test/integration/spawn-children.test.ts`: 118 passed (36 / 14 / 31 / 17
/ 20)
  - `npm run typecheck -w @open-inspect/control-plane`: exit 0
- `npx eslint` and `prettier --check` (3.8.4) on the three touched
files: clean

Related: ColeMurray#2191 applies the same membership rule to `POST
/sessions/:id/children` (spawn).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Updated access checks for child sessions across visibility settings.
Actions on team-owned child sessions now require the person initiating
the action to have current team membership; without it, prompt and
cancel requests are rejected and the child session remains unchanged.
  * Once membership is granted, those actions can proceed as expected.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…Murray#2124)

## Summary

Team mutations wrote their state change and their operation-audit row as
two separate statements. If the audit INSERT failed after a valid
request, the change was already committed: the caller got a 500, the
change persisted with no audit event, and a retry could not recover the
original before-state. The result was a permanent gap in the audit
trail.

This PR commits each change and its audit row in a single D1 batch, the
same way `createWithLead` already does. If the audit write fails, both
roll back.

Affected mutations. Each was reproduced on `main` by forcing the audit
INSERT to fail:

| Route | Audit action |
| --- | --- |
| `PATCH /teams/:id` | `team.updated` |
| `POST /teams/:id/archive` | `team.archived` |
| `POST /teams/:id/restore` | `team.restored` |
| `PUT /teams/:id/members/:userId` (new member) | `team.member_added` |
| `PUT /teams/:id/members/:userId` (existing member) |
`team.member_role_changed` |
| `DELETE /teams/:id/members/:userId` | `team.member_removed` |
| `POST /teams/:id/join` | `team.member_joined` |

## Changes

- The audit rows use the existing `TeamAuditStore.bind(input, true)`.
Its optional `onlyIfPreviousChanged` flag defaults to `false`; passing
`true` builds the audit INSERT as `INSERT … SELECT … WHERE changes() =
1`. This is the same guard `model-provider-account-atomic-writer.ts` and
`TeamSecretsStore.deleteSecret` use, so the row is written only when the
statement just before it in the batch changed a row. Guarded no-ops,
such as archiving an already-archived team, losing a join/add race, or
the last-lead guard rejecting a demotion or removal, stay unaudited as
before. Any failure rolls back the whole batch. `TeamAuditStore.write`
is removed; `createWithLead` keeps its unconditional audit row.
- `TeamStore.update/archive/restore` and
`TeamMembershipStore.add/addIfJoinable/setRole/remove` take an optional
audit actor (plus the before-state where one exists) and batch the
mutation with its audit row. Without it they behave as before; existing
store tests and seeding use that path.
- The team routes pass the request's actor to the stores and no longer
write audit rows themselves. The route-level `auditTeamEvent` helper is
removed.

## Audit row contents and remaining race

The pattern is still read-then-batch, as in `createWithLead`:

- For `team.updated/archived/restored`, `before` is the team as loaded
at route admission, as it was before this change. `after` is now
`before` plus the requested fields and the new `updatedAt`/`archivedAt`,
not a re-read after commit. If another write lands on the same team
between admission and the batch, `before` and the untouched fields in
`after` can be stale. The fields this request changed are still recorded
correctly.
- For `team.member_role_changed/removed`, `before` is the membership
read just before the batch. A concurrent role change between that read
and the batch can leave `before.role` stale; the guarded UPDATE/DELETE
does not compare against it.
- `team.member_joined` now records the written membership (`teamId`,
`userId`, `role`, `source`, `createdAt`) as `after`; previously it
recorded `{ userId, role }`. `team.member_added` records the same
membership shape as before.

## Tests

- New `teams-routes.test.ts` cases, one per mutation above, create a
`BEFORE INSERT` trigger on `authorization_audit_events` that aborts team
audit rows, call the route, and check that it returns 500 with the
team/membership state unchanged and no audit row. They then drop the
trigger, retry, and check that the change applies with exactly one audit
row whose before/after differ. All 7 fail on `main`: the state change
persists.
- In `packages/control-plane`, `npm run typecheck` exits 0. The team
integration tests (`teams-routes`, `team-member-privacy`, `team-stores`,
`team-secrets-routes`, `team-secrets-store`, `team-secrets`,
`teams-migration`, `audit-event-store`: 140/140 across 8 files) and the
team route unit tests (`src/routes/teams.test.ts`,
`src/routes/team-secrets.test.ts`: 9/9) pass.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Reliability**
* Team and membership changes now succeed or roll back together with
their audit records. If audit recording fails, the associated change is
not applied.
* **Tests**
* Added coverage for audit failures across team and membership
operations, verifying that changes are rolled back and can be retried
successfully.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Problem

A plain team member cannot leave a team through the web app, even though
the API allows it.

- `packages/shared/src/types/team-access.ts:24`: `canLeave` is true for
any member (and for a lead when another lead remains).
`canManageMembers` is false for plain members.
- `packages/control-plane/src/routes/teams.ts:457-467` and
`packages/control-plane/src/routing/route-admission.ts:638-641`: `DELETE
/teams/:id/members/:userId` is admitted with `need: "removeMember"`. It
returns `team_capability_required` only when the caller removes *someone
else* without `canManageMembers`. Self-removal is explicitly allowed.
- `packages/web/src/components/settings/team-members-table.tsx:75`: the
per-row "Remove" button is the only membership-removal control in the
web UI, and it is disabled whenever `canManageMembers` is false. So the
member's own row is disabled too. No other "Leave team" control exists
(`TeamDetail` offers no join/leave action; `canLeave` is not read
anywhere in `packages/web`).

## Fix

In `TeamMembersTable`, the Remove button is enabled when:
- the viewer has `canManageMembers` (unchanged), or
- the row is the viewer's own membership (session user id from
`useAuthSession`, the same id the control plane checks as
`viewer(ctx).userId`) **and** `canLeave` is true.

Role changes and adding members still require `canManageMembers`. Using
`canLeave` for the self row keeps the button disabled for a sole lead,
matching the server's last-lead guard, so the UI doesn't offer an action
that is certain to fail.

An alternative would be a dedicated "Leave team" button in `TeamDetail`.
I kept the existing per-row control to keep this change small; happy to
switch if you'd prefer a separate action.

## Tests

- New test `teams-settings.test.tsx` › "lets a member without manage
capability leave but not remove others". It fails on `main`
(`removeMember` is never called because the button is disabled) and
passes with the fix. It also asserts that another member's Remove button
and the viewer's role select stay disabled.
- New test `teams-settings.test.tsx` › "keeps a managing sole lead from
removing themselves". It fails on `main` (the sole lead's own Remove
button is enabled) and passes with the fix; another member's Remove
button stays enabled.
- `npm test -w @open-inspect/web -- src/components/settings/`: 32 files,
349 tests passed.
- `npx tsc --noEmit` (packages/web), plus prettier and eslint on the two
touched files.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Corrected team member removal permissions: members can remove
themselves when leaving is allowed, while removing others remains
restricted to members with management permissions.
* Removal controls are unavailable when the viewer lacks the applicable
permission or an update is in progress. Management permission alone does
not allow a member to remove themselves when leaving is not permitted,
including when they are the sole lead.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ray#2189)

## Problem

The "Require a team for new sessions" switch in Settings → Teams saves
through `PATCH /api/settings/teams` and then updates only the
`/api/settings/teams` SWR entry
(`packages/web/src/components/settings/teams-settings.tsx:52-54`).

The session composer reads the same policy from a different cache entry.
`ActiveTeamProvider` takes `requireTeamOnCreate` from `useMeTeams()`
(`packages/web/src/hooks/use-active-team.ts:20,90`), which is keyed
`["/api/me/teams", userId]`
(`packages/web/src/hooks/use-teams.ts:94-101`). The settings page keeps
that entry populated, because the settings shell and nav also call
`useMeTeams()`. As a result, when you open Home after changing the
policy, the provider starts from the pre-change snapshot. `loading` is
false because cached data exists, so Home
(`app/(app)/(sidebar)/page.tsx:111-116`) treats the old policy as
settled:

- **After turning the requirement on:** the composer still offers a
no-team session, and `POST /sessions` rejects it with `team_required`
(`packages/control-plane/src/routes/session-create.ts:170-171`).
- **After turning it off, for a user with no team:** creation stays
blocked behind "Join a team to create a session."

How long the stale value lasts depends on SWR deduplication:

- **Last `/api/me/teams` fetch more than `dedupingInterval` (2s) ago:**
Home's mount revalidation fixes the value after one round trip.
- **Last fetch within that interval:** the mount revalidation is
deduplicated and the stale value stays until the next focus or reconnect
revalidation.

## Fix

After a successful policy update, revalidate the membership cache with
`mutate(isMeTeamsCacheKey)`. Every other team mutation in `use-teams.ts`
already does this after it writes.

## Verification

New
`packages/web/src/components/settings/teams-settings-cache.test.tsx`. It
uses one shared SWR cache and the real `TeamsSettings`, `useMeTeams`,
`ActiveTeamProvider` and `useCurrentUserAuthorization`. Only
`browserApiFetch` (backed by a stateful policy stub) and the auth
session are mocked. The test loads Settings, flips the switch, waits for
the save to finish, swaps the tree to `ActiveTeamProvider`, and checks
the first ready render. It runs once per direction (`true` and `false`).

- **Before the fix (clean `main`):** 2 failed. The `true` case got
`requireTeamOnCreate=false` and the `false` case got
`requireTeamOnCreate=true`.
- **After the fix:** 2 passed.
- **Mutation check:** I deleted only the `await
mutate(isMeTeamsCacheKey)` line and both cases failed again with the
same values. Restoring the line made them pass.
- **Throwaway probe (not committed):** I set `dedupingInterval: 0` with
the fix absent. The first Home render showed the stale value and the
correct value arrived after the mount refetch. This confirms the
transient case described above.
- `npx vitest run src/hooks/use-active-team.test.tsx
src/hooks/use-teams.test.tsx src/components/settings/` passed: 35 files,
413 tests.
- `npm run typecheck` in `packages/web` (`next typegen && tsc --noEmit`)
exited with code 0.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Changes to the team requirement for new sessions are now reflected in
the home composer after saving team settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
# Conflicts:
#	docs/GETTING_STARTED.md
#	packages/control-plane/src/db/automation-store.test.ts
#	packages/control-plane/src/router.policy.test.ts
#	packages/control-plane/src/routes/automation-crud.ts
#	packages/control-plane/src/routes/skills.ts
#	packages/control-plane/src/routing/route-admission.ts
#	packages/control-plane/src/scheduler/scheduler.test.ts
#	packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap
#	packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts
#	packages/shared/src/types/automations.test.ts
…oleMurray#2123)

## Summary

Since ColeMurray#2083, `BufferedEventForwarder.send()` declines any non-tool,
non-critical event larger than `MAX_EVENT_BYTES` (1,000,000 bytes)
instead of sending the frame
(`packages/sandbox-runtime/src/sandbox_runtime/event_forwarder.py:152-174`).
`token` events carry cumulative text, so once a text passes the limit,
every later update to it is declined and the rest of that text never
reaches the control plane. The bridge counts output as emitted before it
sends it and ignores the result
(`packages/sandbox-runtime/src/sandbox_runtime/bridge.py:696-707`), so
`execution_complete` still reports `success: true`.

The shape of `token` events differs by harness:

- Claude: every token carries the whole turn's text, joined across each
assistant message in the turn
(`packages/sandbox-runtime/src/sandbox_runtime/harness/claude.py:191-192`,
`:904-909`). A long multi-step turn can therefore lose its final answer
entirely, even when no single message is close to the limit.
- OpenCode: tokens are cumulative per text part
(`packages/sandbox-runtime/src/sandbox_runtime/harness/opencode_stream.py:623-640`).
The final-state fetch after `session.idle` (`:925-988`) compares against
the text the harness emitted, not the text that was delivered, so it
does not resend the missing text. Any token it does emit for that part
is over the limit as well.

Nothing else carries the text. `execution_complete` has only `success`,
`error` and cost (`bridge.py:770-776`). The control plane keeps the last
token per message or part
(`packages/control-plane/src/session/event-repository.ts:121-126`). The
web shows the last token per segment
(`packages/web/src/lib/session-socket/event-log.ts:111-132`), and
completion callbacks read the last token
(`packages/shared/src/completion/extractor.ts:191-196`). When the
overflowing text is the last one in the turn, as it always is with
Claude, the stored, displayed and posted answer is the last snapshot
under the limit, and nothing marks it as incomplete.

## Fix

In `emit`, the bridge now notes a token that `send()` declined: `send()`
returned `False` and the token is over the limit. Any other `False`
return for a token means the forwarder buffered it for the next
connection. The forwarder stamps `sandboxId` and `timestamp` in place
before it sizes an event, so the bridge measures the same bytes. If the
harness otherwise succeeded, the turn fails with "The agent's response
exceeded the event size limit and was not delivered in full." and logs
`prompt.text_undelivered`, next to the existing no-output guard
(`bridge.py:734-743`).

The text delivered before the limit stays in the timeline, followed by
"Execution failed: …"
(`packages/web/src/components/session-timeline.tsx:667-672`). Callbacks
receive `success: false` with the error. A harness error or a
cancellation still takes precedence. The forwarder is unchanged:
declining the frame is still right, since `MAX_EVENT_BYTES` keeps each
event under the Durable Object SQLite row limit
(`packages/sandbox-runtime/src/sandbox_runtime/event_size.py:8-12`).
Tool calls are unaffected because ColeMurray#2083 truncates them and marks them
`truncated`.

Any declined token fails the turn. With OpenCode, that includes an
earlier text part that overflowed even when a later part was delivered,
because the stored text of the earlier part is then incomplete.

Alternative: deliver bounded text instead of failing. The current
protocol has no complete representation. Each token replaces the stored
text for its message or part, and the web and callbacks read only the
last token. A delta would therefore replace the stored answer, and a
split into several parts would still show and post only the last part. A
bounded prefix is what is already stored. A bounded tail would keep the
end of the answer but silently drop its start: the `token` schema
(`packages/shared/src/types/sandbox-events.ts:126-130`) has no
truncation marker, and the control plane strips unknown fields when it
parses sandbox events. ColeMurray#2083 also deliberately left non-tool events
untruncated. If you'd rather keep the tail, it needs a `truncated` field
on `token` events, as `tool_call` has, plus a marker in the UI.

## Reproduction


`TestAssistantTextDelivery::test_text_past_the_event_limit_fails_the_turn`
in `packages/sandbox-runtime/tests/test_bridge_event_buffer.py` streams
a short token, then a cumulative token over the limit, through
`_handle_prompt` and the real forwarder bound to a fake socket. On
`main` (`700f9145`), only the first token reaches the socket, and the
test fails on the outcome:

```text
>       assert completion["success"] is False
E       assert True is False
```

`test_text_the_envelope_pushes_past_the_limit_fails_the_turn` uses a
token that is exactly at the limit until the forwarder stamps
`sandboxId` on it. It also fails on `main` with `assert True is False`,
and it fails if the bridge sizes the token before the forwarder stamps
it.

I also ran a check that is not committed. It drives the real harnesses
through `_handle_prompt` and the real forwarder. On `main`:

- Claude harness, with a 600 KB message followed by a 450 KB message
that ends in the answer: the 1,050,153-byte token is declined, and only
the first message's text is sent. The completion is `{'success': True,
'messageCostUsd': 0.1}`.
- OpenCode, with one text part that grows to 1.1 MB: the 900 KB snapshot
is sent, and the later snapshots (1,100,135 and 1,100,169 bytes) are
declined. The completion is `{'success': True}`.

With this change, both runs report `success: false` with the error
above.

Two further tests pass on `main` and guard the fix itself.
`test_text_buffered_while_disconnected_does_not_fail_the_turn` checks
that a token buffered while no connection is bound does not fail the
turn and is delivered on bind.
`test_harness_error_outranks_undelivered_text` checks that the harness's
own error is reported rather than the size error.

## Tests

In `packages/sandbox-runtime`:

- `pytest tests/`: 1394 passed, 3 skipped
- `ruff check` and `ruff format --check` on the two touched files: clean
- `mypy src/`: 6 errors, the same 6 as on `main`, in
`auth/github_app.py`, `entrypoint.py` and `managed_skills.py`; none in
`bridge.py`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Prompts now report a failure when assistant text cannot be delivered
because it exceeds the supported message size, instead of appearing to
complete successfully.
* Existing errors continue to take precedence when a prompt fails for
another reason.
* Assistant text buffered while disconnected continues to be delivered
after the connection is restored, without causing the prompt to fail.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 326 files, which is 26 over the limit of 300.

To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 5b45d207-7f6a-4a44-89e7-174d8de5ebac

📥 Commits

Reviewing files that changed from the base of the PR and between 1ab03cc and f8ea83c.

⛔ Files ignored due to path filters (4)
  • packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap is excluded by !**/*.snap
  • packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap is excluded by !**/*.snap
  • packages/modal-infra/uv.lock is excluded by !**/*.lock
  • packages/sandbox-runtime/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (326)
  • CHANGELOG.md
  • docs/AUTH.md
  • docs/GETTING_STARTED.md
  • docs/HOW_IT_WORKS.md
  • docs/IMAGE_PREBUILD.md
  • docs/MODAL_DOCKER.md
  • docs/plans/sandbox-lifecycle-manager-refactor.md
  • docs/plans/sandbox-lifecycle-refactor-verification.md
  • eslint.config.js
  • packages/control-plane/README.md
  • packages/control-plane/src/auth/github-app.cache.test.ts
  • packages/control-plane/src/auth/github-app.test.ts
  • packages/control-plane/src/auth/github-app.ts
  • packages/control-plane/src/authorization/owned-resource-admission.test.ts
  • packages/control-plane/src/authorization/owned-resource-admission.ts
  • packages/control-plane/src/authorization/request-audit.ts
  • packages/control-plane/src/authorization/resource-viewer.ts
  • packages/control-plane/src/autofix/handler.test.ts
  • packages/control-plane/src/autofix/handler.ts
  • packages/control-plane/src/autofix/service.test.ts
  • packages/control-plane/src/autofix/service.ts
  • packages/control-plane/src/automation/authorization-guard.test.ts
  • packages/control-plane/src/automation/authorization-guard.ts
  • packages/control-plane/src/db/analytics-store.ts
  • packages/control-plane/src/db/automation-store.test.ts
  • packages/control-plane/src/db/automation-store.ts
  • packages/control-plane/src/db/environments.ts
  • packages/control-plane/src/db/session-index-batch.test.ts
  • packages/control-plane/src/db/session-index.ts
  • packages/control-plane/src/db/session-repositories.ts
  • packages/control-plane/src/db/session-visibility.ts
  • packages/control-plane/src/db/team-audit.ts
  • packages/control-plane/src/db/team-memberships.ts
  • packages/control-plane/src/db/team-repository-grants.test.ts
  • packages/control-plane/src/db/team-repository-grants.ts
  • packages/control-plane/src/db/teams.ts
  • packages/control-plane/src/http/request-context.ts
  • packages/control-plane/src/image-builds/credential-scope.ts
  • packages/control-plane/src/image-builds/daytona-adapter.test.ts
  • packages/control-plane/src/image-builds/e2b-adapter.test.ts
  • packages/control-plane/src/image-builds/modal-adapter.test.ts
  • packages/control-plane/src/image-builds/modal-adapter.ts
  • packages/control-plane/src/image-builds/opencomputer-adapter.test.ts
  • packages/control-plane/src/image-builds/planner.test.ts
  • packages/control-plane/src/image-builds/planner.ts
  • packages/control-plane/src/image-builds/scheduler.test.ts
  • packages/control-plane/src/image-builds/scheduler.ts
  • packages/control-plane/src/image-builds/scope.test.ts
  • packages/control-plane/src/image-builds/scope.ts
  • packages/control-plane/src/image-builds/types.ts
  • packages/control-plane/src/image-builds/vercel-adapter.test.ts
  • packages/control-plane/src/repos/cache.test.ts
  • packages/control-plane/src/repos/cache.ts
  • packages/control-plane/src/router.create-session.test.ts
  • packages/control-plane/src/router.policy.test.ts
  • packages/control-plane/src/router.spawn-child-grants.test.ts
  • packages/control-plane/src/router.spawn-child.test.ts
  • packages/control-plane/src/routes/automation-create.test.ts
  • packages/control-plane/src/routes/automation-crud.ts
  • packages/control-plane/src/routes/automation-environment-selection.test.ts
  • packages/control-plane/src/routes/automation-executor.ts
  • packages/control-plane/src/routes/automation-lifecycle.test.ts
  • packages/control-plane/src/routes/automation-lifecycle.ts
  • packages/control-plane/src/routes/automation-list.test.ts
  • packages/control-plane/src/routes/automation-list.ts
  • packages/control-plane/src/routes/automation-runs.test.ts
  • packages/control-plane/src/routes/automation-runs.ts
  • packages/control-plane/src/routes/automation-shared.ts
  • packages/control-plane/src/routes/automation-slack-settings.ts
  • packages/control-plane/src/routes/automation-update.test.ts
  • packages/control-plane/src/routes/automation-validation.test.ts
  • packages/control-plane/src/routes/automation-validation.ts
  • packages/control-plane/src/routes/automations.test-support.ts
  • packages/control-plane/src/routes/automations.ts
  • packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts
  • packages/control-plane/src/routes/environment-secrets.ts
  • packages/control-plane/src/routes/environments-catalog.test.ts
  • packages/control-plane/src/routes/environments-target-denied.test.ts
  • packages/control-plane/src/routes/environments.ts
  • packages/control-plane/src/routes/github-reviewer-token.ts
  • packages/control-plane/src/routes/image-builds.repository-grants.test.ts
  • packages/control-plane/src/routes/image-builds.trigger.test.ts
  • packages/control-plane/src/routes/image-builds.ts
  • packages/control-plane/src/routes/integration-settings.ts
  • packages/control-plane/src/routes/repos.ts
  • packages/control-plane/src/routes/repository-grants.test-support.ts
  • packages/control-plane/src/routes/secrets.repository-grants.test.ts
  • packages/control-plane/src/routes/secrets.ts
  • packages/control-plane/src/routes/session-child-spawn.ts
  • packages/control-plane/src/routes/session-children.test.ts
  • packages/control-plane/src/routes/session-children.ts
  • packages/control-plane/src/routes/session-create.ts
  • packages/control-plane/src/routes/session-index.ts
  • packages/control-plane/src/routes/session-target-authorization.test.ts
  • packages/control-plane/src/routes/session-target-authorization.ts
  • packages/control-plane/src/routes/shared.ts
  • packages/control-plane/src/routes/skills.repository-grants.test.ts
  • packages/control-plane/src/routes/skills.ts
  • packages/control-plane/src/routes/team-ownership.ts
  • packages/control-plane/src/routes/teams.ts
  • packages/control-plane/src/routes/workspace-repository-authorization.test.ts
  • packages/control-plane/src/routes/workspace-repository-authorization.ts
  • packages/control-plane/src/routing/route-admission.ts
  • packages/control-plane/src/sandbox/client.test.ts
  • packages/control-plane/src/sandbox/client.ts
  • packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts
  • packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts
  • packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts
  • packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts
  • packages/control-plane/src/sandbox/lifecycle/manager.ts
  • packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts
  • packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts
  • packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts
  • packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts
  • packages/control-plane/src/sandbox/provider-factory.ts
  • packages/control-plane/src/sandbox/providers/modal-backends.test.ts
  • packages/control-plane/src/sandbox/providers/modal-provider.test.ts
  • packages/control-plane/src/sandbox/providers/modal-provider.ts
  • packages/control-plane/src/sandbox/providers/vercel/provider.test.ts
  • packages/control-plane/src/scheduler/scheduler.test.ts
  • packages/control-plane/src/scheduler/scheduler.ts
  • packages/control-plane/src/session/components.credentials.test.ts
  • packages/control-plane/src/session/components.ts
  • packages/control-plane/src/session/message-repository.test.ts
  • packages/control-plane/src/session/message-repository.ts
  • packages/control-plane/src/session/pull-request-refresh.test.ts
  • packages/control-plane/src/session/pull-request-refresh.ts
  • packages/control-plane/src/session/pull-request-service.per-branch.test.ts
  • packages/control-plane/src/session/pull-request-service.test.ts
  • packages/control-plane/src/session/pull-request-service.ts
  • packages/control-plane/src/session/scm-credentials-service.test.ts
  • packages/control-plane/src/session/scm-credentials-service.ts
  • packages/control-plane/src/skills/git-import.test.ts
  • packages/control-plane/src/skills/git-import.ts
  • packages/control-plane/src/source-control/credential-scope.test.ts
  • packages/control-plane/src/source-control/credential-scope.ts
  • packages/control-plane/src/source-control/index.ts
  • packages/control-plane/src/source-control/provider-from-env.test.ts
  • packages/control-plane/src/source-control/providers/github-provider.test.ts
  • packages/control-plane/src/source-control/providers/github-provider.ts
  • packages/control-plane/src/source-control/providers/gitlab-provider.test.ts
  • packages/control-plane/src/source-control/providers/gitlab-provider.ts
  • packages/control-plane/src/source-control/repository-scope.test.ts
  • packages/control-plane/src/source-control/repository-scope.ts
  • packages/control-plane/src/source-control/session-scope.test.ts
  • packages/control-plane/src/source-control/session-scope.ts
  • packages/control-plane/src/source-control/types.ts
  • packages/control-plane/test/integration/automation-authorization.test.ts
  • packages/control-plane/test/integration/automation-executor-permissions.test.ts
  • packages/control-plane/test/integration/automation-run-session-privacy.test.ts
  • packages/control-plane/test/integration/automation-team-execution.test.ts
  • packages/control-plane/test/integration/automation-team-grants.test.ts
  • packages/control-plane/test/integration/automation-team-ownership.test.ts
  • packages/control-plane/test/integration/child-session-ops.test.ts
  • packages/control-plane/test/integration/environment-secret-import-grants.test.ts
  • packages/control-plane/test/integration/environment-secret-import-identity.test.ts
  • packages/control-plane/test/integration/environment-store.test.ts
  • packages/control-plane/test/integration/environment-team-ownership.test.ts
  • packages/control-plane/test/integration/environments-catalog.test.ts
  • packages/control-plane/test/integration/environments-routes.test.ts
  • packages/control-plane/test/integration/github-reviewer-token.test.ts
  • packages/control-plane/test/integration/helpers.ts
  • packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts
  • packages/control-plane/test/integration/modal-backend-builds.test.ts
  • packages/control-plane/test/integration/ownership-test-helpers.ts
  • packages/control-plane/test/integration/response-compatibility.test.ts
  • packages/control-plane/test/integration/route-admission-matrix.test.ts
  • packages/control-plane/test/integration/sandbox-state-retention.test.ts
  • packages/control-plane/test/integration/sandbox-vm-reconciliation.test.ts
  • packages/control-plane/test/integration/scheduler-slack-team-steering.test.ts
  • packages/control-plane/test/integration/scoped-installation-token.test.ts
  • packages/control-plane/test/integration/session-environment-ownership.test.ts
  • packages/control-plane/test/integration/spawn-children.test.ts
  • packages/control-plane/test/integration/team-grants.test.ts
  • packages/control-plane/test/integration/teams-routes.test.ts
  • packages/docs/content/docs/administration/security.mdx
  • packages/docs/content/docs/configure/prebuilt-images.mdx
  • packages/docs/content/docs/reference/sandbox-environment.mdx
  • packages/github-bot/README.md
  • packages/modal-infra/.env.example
  • packages/modal-infra/README.md
  • packages/modal-infra/pyproject.toml
  • packages/modal-infra/src/app.py
  • packages/modal-infra/src/clone_token.py
  • packages/modal-infra/src/sandbox/build_session.py
  • packages/modal-infra/src/sandbox/launch.py
  • packages/modal-infra/src/sandbox/manager.py
  • packages/modal-infra/src/sandbox/models.py
  • packages/modal-infra/src/sandbox/vcs_env.py
  • packages/modal-infra/src/web_api.py
  • packages/modal-infra/tests/test_agent_slack_notify_env.py
  • packages/modal-infra/tests/test_build_sandbox_lifecycle.py
  • packages/modal-infra/tests/test_clone_token.py
  • packages/modal-infra/tests/test_code_server.py
  • packages/modal-infra/tests/test_llm_secrets.py
  • packages/modal-infra/tests/test_sandbox_env_vars.py
  • packages/modal-infra/tests/test_sandbox_launch.py
  • packages/modal-infra/tests/test_sandbox_resources.py
  • packages/modal-infra/tests/test_ttyd.py
  • packages/modal-infra/tests/test_tunnel_ports.py
  • packages/modal-infra/tests/test_vm_resolve.py
  • packages/modal-infra/tests/test_vnc.py
  • packages/modal-infra/tests/test_web_api_build_sandbox.py
  • packages/modal-infra/tests/test_web_api_create_sandbox.py
  • packages/sandbox-runtime/pyproject.toml
  • packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py
  • packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py
  • packages/sandbox-runtime/src/sandbox_runtime/bridge.py
  • packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py
  • packages/sandbox-runtime/tests/conftest.py
  • packages/sandbox-runtime/tests/test_bridge_event_buffer.py
  • packages/sandbox-runtime/tests/test_git_credential_helper.py
  • packages/shared/src/rbac.ts
  • packages/shared/src/types/audit-events.test.ts
  • packages/shared/src/types/audit-events.ts
  • packages/shared/src/types/automations.test.ts
  • packages/shared/src/types/automations.ts
  • packages/shared/src/types/environments.test.ts
  • packages/shared/src/types/environments.ts
  • packages/shared/src/types/index.ts
  • packages/shared/src/types/repository-catalog.ts
  • packages/shared/src/types/session-access.test.ts
  • packages/shared/src/types/session-access.ts
  • packages/shared/src/types/team-access.test.ts
  • packages/shared/src/types/team-access.ts
  • packages/shared/src/types/team-id.ts
  • packages/shared/src/types/teams.ts
  • packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.test.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/[id]/page.test.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/[id]/page.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/new/page.test.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/new/page.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/page.test.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/page.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/templates/page.test.tsx
  • packages/web/src/app/(app)/(sidebar)/automations/templates/page.tsx
  • packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx
  • packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx
  • packages/web/src/app/(app)/(sidebar)/sessions/page.tsx
  • packages/web/src/app/api/automations/[id]/route.test.ts
  • packages/web/src/app/api/automations/[id]/route.ts
  • packages/web/src/app/api/automations/route.test.ts
  • packages/web/src/app/api/automations/route.ts
  • packages/web/src/app/api/environments/[id]/route.test.ts
  • packages/web/src/app/api/environments/[id]/route.ts
  • packages/web/src/app/api/environments/route.test.ts
  • packages/web/src/app/api/environments/route.ts
  • packages/web/src/app/api/repos/route.test.ts
  • packages/web/src/app/api/repos/route.ts
  • packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts
  • packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts
  • packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts
  • packages/web/src/app/api/teams/[id]/repository-grants/route.ts
  • packages/web/src/app/providers.tsx
  • packages/web/src/components/automations/automation-collection.test.tsx
  • packages/web/src/components/automations/automation-collection.tsx
  • packages/web/src/components/automations/automation-form-policy.ts
  • packages/web/src/components/automations/automation-form.test.tsx
  • packages/web/src/components/automations/automation-form.tsx
  • packages/web/src/components/automations/automation-target-picker-menu.tsx
  • packages/web/src/components/automations/automation-target-selection.test.ts
  • packages/web/src/components/automations/automation-target-selection.ts
  • packages/web/src/components/automations/automations-list.test.tsx
  • packages/web/src/components/automations/automations-list.tsx
  • packages/web/src/components/automations/template-gallery.tsx
  • packages/web/src/components/automations/use-automation-targets.ts
  • packages/web/src/components/resource-team-field.tsx
  • packages/web/src/components/session-header.test.tsx
  • packages/web/src/components/session-header.tsx
  • packages/web/src/components/session-right-sidebar.test.tsx
  • packages/web/src/components/session-right-sidebar.tsx
  • packages/web/src/components/session-sidebar.tsx
  • packages/web/src/components/session-target-picker.test.tsx
  • packages/web/src/components/session-target-picker.tsx
  • packages/web/src/components/settings/audit-log-settings.test.tsx
  • packages/web/src/components/settings/audit-log-settings.tsx
  • packages/web/src/components/settings/environment-access.test.ts
  • packages/web/src/components/settings/environment-access.ts
  • packages/web/src/components/settings/environment-detail.tsx
  • packages/web/src/components/settings/environment-form.test.tsx
  • packages/web/src/components/settings/environment-form.tsx
  • packages/web/src/components/settings/environments-settings.test.tsx
  • packages/web/src/components/settings/environments-settings.tsx
  • packages/web/src/components/settings/team-detail.tsx
  • packages/web/src/components/settings/team-members-table.tsx
  • packages/web/src/components/settings/teams-settings-cache.test.tsx
  • packages/web/src/components/settings/teams-settings.test.tsx
  • packages/web/src/components/settings/teams-settings.tsx
  • packages/web/src/components/team-switcher.tsx
  • packages/web/src/components/teams/team-automations.tsx
  • packages/web/src/components/teams/team-environments.tsx
  • packages/web/src/components/teams/team-page.tsx
  • packages/web/src/components/teams/team-repositories.test.tsx
  • packages/web/src/components/teams/team-repositories.tsx
  • packages/web/src/components/teams/team-secrets.test.tsx
  • packages/web/src/components/teams/teams-pages.test.tsx
  • packages/web/src/hooks/use-active-team.ts
  • packages/web/src/hooks/use-automation-actions.ts
  • packages/web/src/hooks/use-automation-scope.ts
  • packages/web/src/hooks/use-automations.test.tsx
  • packages/web/src/hooks/use-automations.ts
  • packages/web/src/hooks/use-can-create-automation.ts
  • packages/web/src/hooks/use-environments.test.tsx
  • packages/web/src/hooks/use-environments.ts
  • packages/web/src/hooks/use-repos.test.tsx
  • packages/web/src/hooks/use-repos.ts
  • packages/web/src/hooks/use-resource-teams.test.tsx
  • packages/web/src/hooks/use-resource-teams.ts
  • packages/web/src/hooks/use-session-inspector-tab.test.tsx
  • packages/web/src/hooks/use-session-inspector-tab.ts
  • packages/web/src/hooks/use-session-target-picker.test.ts
  • packages/web/src/hooks/use-session-target-picker.ts
  • packages/web/src/hooks/use-team-capabilities.ts
  • packages/web/src/hooks/use-teams.test.tsx
  • packages/web/src/hooks/use-warm-draft-session.test.tsx
  • packages/web/src/hooks/use-warm-draft-session.ts
  • packages/web/src/lib/automation-authorization.test.ts
  • packages/web/src/lib/automation-authorization.ts
  • packages/web/src/lib/automation-cache.test.tsx
  • packages/web/src/lib/automation-cache.ts
  • packages/web/src/lib/automation-navigation.test.ts
  • packages/web/src/lib/automation-navigation.ts
  • packages/web/src/lib/swr-fetch-error.ts
  • scripts/lint-sandbox-boundaries.test.mjs
  • terraform/environments/production/modal.tf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

This is an automated nightly unsafe-cast remediation sweep. It replaces
three remaining persisted SQLite message-row assertions with
parse-don't-assert validation, following the TypeScript Coding Standards
guidance for unsafe-cast / parse-don't-assert and the Zod
boundary-validation pattern established in PR ColeMurray#807.

| file:line | risk | cast removed | how it was fixed |
| --- | --- | --- | --- |
| `packages/control-plane/src/session/message-repository.ts:354` |
MEDIUM | `result.toArray() as Array<{ callback_context: string | null;
source: string | null }>` for callback delivery state | Added
`messageCallbackContextRowSchema` and parsed rows through existing
`parseStorageRows`; valid `callback_context: null` remains accepted. |
| `packages/control-plane/src/session/message-repository.ts:450` |
MEDIUM | `result.toArray() as Array<{ status?: unknown; created_at:
number; started_at: number | null }>` before recording completion and
canonical events | Added `messageCompletionStateRowSchema` and parsed
rows through existing `parseStorageRows`; `status` remains `unknown` so
malformed statuses preserve the existing null/skip behavior. |
| `packages/control-plane/src/session/message-repository.ts:528` |
MEDIUM | `result.toArray() as Array<{ author_id: string }>` for
processing-message author state | Added
`messageProcessingAuthorRowSchema` and parsed rows through existing
`parseStorageRows`. |

Verification:

| command | result |
| --- | --- |
| `npm run format -- --write
packages/control-plane/src/session/message-repository.ts
packages/control-plane/src/session/message-repository.test.ts` | Passed
|
| `npm test -w @open-inspect/control-plane --
src/session/message-repository.test.ts --maxWorkers=1` | Passed, 48
tests |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run build -w @open-inspect/control-plane` | Passed |
| `npm run typecheck` | Passed |
| `npm run lint` | Passed |
| `npm run format` | Passed |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | Passed,
360 files / 6136 tests |

No dependency changes were made. Existing open PRs labeled
`automation:unsafe-cast` were checked first; this sweep excludes their
covered findings.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/84c0638f9356482a7bbd440369e50e12)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of invalid stored session message data. Malformed
timestamps, callback context, completion state, or processing authors
now trigger a storage integrity error instead of being accepted.
* Prevented further database operations when a malformed completion
record is detected.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format ✅
Init ✅
Validate ✅
Tests ✅
Modal module tests ✅

Pushed by: @rhlsthrm, Action: pull_request

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Terraform Plan Results

Status: ✅ Success

Show Plan
terraform_data.cloudflare_custom_domain_gate: Refreshing state... [id=09b89c9b-996a-d28b-1f9c-08760a492dac]
terraform_data.sign_in_provider_gate: Refreshing state... [id=b29a3d55-0be5-fb92-10a9-027df10c4b75]
terraform_data.access_control_gate: Refreshing state... [id=2b965617-b42b-4b42-5ea5-a1c3c05f10be]
local_file.web_app_wrangler_production[0]: Refreshing state... [id=d58ccd8dd2962c70f7cff2ffac9821e9e711af31]
module.github_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=e0848d433a4f466cafd4ed5d140aad7d]
cloudflare_queue.image_build_finalization_dlq: Refreshing state... [id=61535c686d8546099cfddcf39833572b]
cloudflare_queue.slack_completion_delivery_dlq[0]: Refreshing state... [id=396865e4939b4160937b2dc9ad5dabe1]
module.slack_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=ab5c371c8bc04a938ff2f71809933aa0]
module.session_index_kv.cloudflare_workers_kv_namespace.this: Refreshing state... [id=ea0a253d5cb64d75a841acb88040cd2f]
cloudflare_queue.github_autofix_dlq[0]: Refreshing state... [id=3a27213aeba149d4b7cbf2d3551842f8]
cloudflare_d1_database.main: Refreshing state... [id=f747a908-5c69-45a1-86ab-ceb5250cf5e0]
cloudflare_r2_bucket.media: Refreshing state... [id=open-inspect-media-codos]
cloudflare_queue.slack_completion_delivery[0]: Refreshing state... [id=247b1100bac2408684d6a75c1bca0d28]
cloudflare_queue.github_autofix[0]: Refreshing state... [id=033a23f13783415385b2f8799416c20f]
cloudflare_queue.image_build_finalization: Refreshing state... [id=a0647323f7424e778b9d59da50dc55cf]
module.linear_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=777f94c3595f4de680c256a4e5fc6653]
data.external.modal_source_hash[0]: Reading...
null_resource.control_plane_build: Refreshing state... [id=5528656732809257011]
null_resource.github_bot_build[0]: Refreshing state... [id=6908611508837039030]
module.modal_app[0].null_resource.modal_secrets[0]: Refreshing state... [id=8477737195823217344]
random_password.service_auth_secret_github_bot: Refreshing state... [id=none]
random_password.service_auth_secret_slack_bot: Refreshing state... [id=none]
random_bytes.provider_accounts_encryption_key: Refreshing state...
null_resource.slack_bot_build[0]: Refreshing state... [id=139287417073036493]
random_password.service_auth_secret_web: Refreshing state... [id=none]
null_resource.web_app_cloudflare_build[0]: Refreshing state... [id=3047571768604585709]
random_password.service_auth_secret_linear_bot: Refreshing state... [id=none]
random_password.image_callback_token_pepper: Refreshing state... [id=none]
null_resource.linear_bot_build[0]: Refreshing state... [id=3415715852648161979]
module.slack_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=375c2c6875904657bce05c62c8048c76]
data.external.modal_source_hash[0]: Read complete after 0s [id=-]
module.linear_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=049cd48117bc48b9b4332683a97d0a0e]
module.modal_app[0].null_resource.modal_deploy: Refreshing state... [id=8891027293183044333]
null_resource.d1_migrations: Refreshing state... [id=263751651589333239]
module.linear_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=14ed4788-feb0-4b6e-9ceb-0d1c7b47bf4d]
module.slack_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=ee23c16d-82c8-45c5-bf5b-df2b18e0f5a2]
module.linear_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=67610c8f-eb33-4aaa-954b-154744e911d3]
module.control_plane_worker.cloudflare_worker.this: Refreshing state... [id=3457352971a74b89be5ed3700db48a8e]
module.slack_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=8bbfc3b8-8d25-47ee-8904-22447bbf8773]
cloudflare_queue_consumer.slack_completion_delivery[0]: Refreshing state...
module.control_plane_worker.cloudflare_worker_version.this: Refreshing state... [id=b93c0070-0c3d-4ffa-83b8-567b6777d98c]
module.control_plane_worker.cloudflare_workers_deployment.this: Refreshing state... [id=cebefa4e-9f03-4b7e-a545-86f94cab5c9a]
module.control_plane_worker.cloudflare_workers_cron_trigger.this[0]: Refreshing state... [id=open-inspect-control-plane-codos]
null_resource.web_app_cloudflare_deploy[0]: Refreshing state... [id=5183708612434619948]
cloudflare_queue_consumer.image_build_finalization: Refreshing state...
module.github_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=fa832fd890a14336bc3c63305e9bc36f]
null_resource.web_app_cloudflare_secrets[0]: Refreshing state... [id=8981633407656564074]
module.github_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=f7356b06-2065-4b43-9577-f9d17e05d1eb]
module.github_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=624981be-d5b4-467f-a418-465957bfaf1d]
cloudflare_queue_consumer.github_autofix[0]: Refreshing state...

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # local_file.web_app_wrangler_production[0] will be created
  + resource "local_file" "web_app_wrangler_production" {
      + content              = <<-EOT
            name = "open-inspect-web-codos"
            main = ".open-next/worker.js"
            compatibility_date = "2025-08-15"
            compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
            # Keep-names makes esbuild emit __name() calls, which leak into next-themes'
            # inline script and throw in the browser.
            keep_names = false
            
            # A custom-domain deployment has one canonical browser origin.
            workers_dev = true
            
            [vars]
            CONTROL_PLANE_URL = "https://open-inspect-control-plane-codos.opencodos.workers.dev"
            NEXT_PUBLIC_WS_URL = "wss://open-inspect-control-plane-codos.opencodos.workers.dev"
            NEXT_PUBLIC_SANDBOX_PROVIDER = "modal"
            NEXT_PUBLIC_APP_NAME = "Open-Inspect"
            NEXT_PUBLIC_APP_ICON_URL = ""
            
            [assets]
            directory = ".open-next/assets"
            binding = "ASSETS"
            
            [[services]]
            binding = "CONTROL_PLANE_WORKER"
            service = "open-inspect-control-plane-codos"
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "../../..//packages/web/wrangler.production.toml"
      + id                   = (known after apply)
    }

  # null_resource.control_plane_build must be replaced
-/+ resource "null_resource" "control_plane_build" {
      ~ id       = "5528656732809257011" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:31Z" -> (known after apply)
        }
    }

  # null_resource.github_bot_build[0] must be replaced
-/+ resource "null_resource" "github_bot_build" {
      ~ id       = "6908611508837039030" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:30Z" -> (known after apply)
        }
    }

  # null_resource.linear_bot_build[0] must be replaced
-/+ resource "null_resource" "linear_bot_build" {
      ~ id       = "3415715852648161979" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:31Z" -> (known after apply)
        }
    }

  # null_resource.slack_bot_build[0] must be replaced
-/+ resource "null_resource" "slack_bot_build" {
      ~ id       = "139287417073036493" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:30Z" -> (known after apply)
        }
    }

  # null_resource.web_app_cloudflare_build[0] must be replaced
-/+ resource "null_resource" "web_app_cloudflare_build" {
      ~ id       = "3047571768604585709" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:30Z" -> (known after apply)
        }
    }

  # null_resource.web_app_cloudflare_deploy[0] must be replaced
-/+ resource "null_resource" "web_app_cloudflare_deploy" {
      ~ id       = "5183708612434619948" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:31:55Z" -> (known after apply)
        }
    }

  # module.control_plane_worker.cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "3457352971a74b89be5ed3700db48a8e"
        name           = "open-inspect-control-plane-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [
              - {
                  - namespace_id   = "34735ba6d2804d67a82cf0bdf5a3175f" -> null
                  - namespace_name = "open-inspect-control-plane-codos_SessionDO" -> null
                  - worker_id      = "3457352971a74b89be5ed3700db48a8e" -> null
                  - worker_name    = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
          ~ queues                       = [
              - {
                  - queue_consumer_id = "4e24da4810c84b3e9e80ea014860d145" -> null
                  - queue_id          = "033a23f13783415385b2f8799416c20f" -> null
                  - queue_name        = "open-inspect-github-autofix-codos" -> null
                },
              - {
                  - queue_consumer_id = "f37fe99c4658470aa36767dfb68c3d6f" -> null
                  - queue_id          = "a0647323f7424e778b9d59da50dc55cf" -> null
                  - queue_name        = "open-inspect-image-build-finalization-codos" -> null
                },
            ] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "7aa4fa7a556a48708d1ebd7bbba3263a" -> null
                  - name = "open-inspect-web-codos" -> null
                },
              - {
                  - id   = "fa832fd890a14336bc3c63305e9bc36f" -> null
                  - name = "open-inspect-github-bot-codos" -> null
                },
              - {
                  - id   = "049cd48117bc48b9b4332683a97d0a0e" -> null
                  - name = "open-inspect-linear-bot-codos" -> null
                },
              - {
                  - id   = "375c2c6875904657bce05c62c8048c76" -> null
                  - name = "open-inspect-slack-bot-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:31:51Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.control_plane_worker.cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:31:53Z" -> (known after apply)
      ~ id                  = "b93c0070-0c3d-4ffa-83b8-567b6777d98c" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      ~ migration_tag       = "v1" -> (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/control-plane/dist/index.js" -> null
              - content_sha256 = "854e8ac71750e38324cf7b66d059d72ca16e62fb6074fd2ecc7f976909621078" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/control-plane/dist/index.js"
              + content_sha256 = "cbbffb441718f28d4cdcb01e5294c596dfeb8a4d5b4d0107d3d63dd67c10894f"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 76 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 91 -> (known after apply)
      ~ urls                = [] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.control_plane_worker.cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:31:55Z" -> (known after apply)
      ~ id           = "cebefa4e-9f03-4b7e-a545-86f94cab5c9a" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "b93c0070-0c3d-4ffa-83b8-567b6777d98c" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.github_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "fa832fd890a14336bc3c63305e9bc36f"
        name           = "open-inspect-github-bot-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "3457352971a74b89be5ed3700db48a8e" -> null
                  - name = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:31:55Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.github_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:31:56Z" -> (known after apply)
      ~ id                  = "f7356b06-2065-4b43-9577-f9d17e05d1eb" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/github-bot/dist/index.js" -> null
              - content_sha256 = "5cdbd14abb2645c367130bc1db746dca2929dc7ea270f57914d1c3cdc084bc44" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/github-bot/dist/index.js"
              + content_sha256 = "23f9979b7b93a642f841ec5954878c060e916f0e5ea6346f182c0c0daf8b8efd"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 74 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 40 -> (known after apply)
      ~ urls                = [
          - "https://f7356b06-open-inspect-github-bot-codos.opencodos.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.github_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:31:57Z" -> (known after apply)
      ~ id           = "624981be-d5b4-467f-a418-465957bfaf1d" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "f7356b06-2065-4b43-9577-f9d17e05d1eb" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "049cd48117bc48b9b4332683a97d0a0e"
        name           = "open-inspect-linear-bot-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "3457352971a74b89be5ed3700db48a8e" -> null
                  - name = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:29:31Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:29:32Z" -> (known after apply)
      ~ id                  = "14ed4788-feb0-4b6e-9ceb-0d1c7b47bf4d" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/linear-bot/dist/index.js" -> null
              - content_sha256 = "896f64892838c78a55e22e96e5362ddd9e6d308f1d8238b5dcbe5cde6d83f593" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/linear-bot/dist/index.js"
              + content_sha256 = "7b42cf70800722f964d7272de95ecc31f5307b71f4a0a0b2d9d8f68aecc38417"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 80 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 31 -> (known after apply)
      ~ urls                = [
          - "https://14ed4788-open-inspect-linear-bot-codos.opencodos.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:29:33Z" -> (known after apply)
      ~ id           = "67610c8f-eb33-4aaa-954b-154744e911d3" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "14ed4788-feb0-4b6e-9ceb-0d1c7b47bf4d" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.modal_app[0].null_resource.modal_deploy must be replaced
-/+ resource "null_resource" "modal_deploy" {
      ~ id       = "8891027293183044333" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "secrets_created"   = (sensitive value)
          ~ "source_hash"       = "2ba85fecd95506fef751e24b3b0ffa9c6c9c26ff72519878575273c24e06a415" -> "a9117ffe75e842adc92a751db93784b8d2157180329f2428b1a66fbbf8a5e0c0"
            # (3 unchanged elements hidden)
        }
    }

  # module.modal_app[0].null_resource.modal_secrets[0] must be replaced
-/+ resource "null_resource" "modal_secrets" {
      ~ id       = "8477737195823217344" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "secrets_hash"      = (sensitive value)
            # (1 unchanged element hidden)
        }
    }

  # module.slack_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "375c2c6875904657bce05c62c8048c76"
        name           = "open-inspect-slack-bot-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [
              - {
                  - queue_consumer_id = "767c5dfe751c4852a536d98b836cdd94" -> null
                  - queue_id          = "247b1100bac2408684d6a75c1bca0d28" -> null
                  - queue_name        = "open-inspect-slack-completion-codos" -> null
                },
            ] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "3457352971a74b89be5ed3700db48a8e" -> null
                  - name = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:29:31Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.slack_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:29:32Z" -> (known after apply)
      ~ id                  = "ee23c16d-82c8-45c5-bf5b-df2b18e0f5a2" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/slack-bot/dist/index.js" -> null
              - content_sha256 = "d461e51f22ef13a3c3e7048bad91ed13f450872b4af43e534acce447d7d50a65" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/slack-bot/dist/index.js"
              + content_sha256 = "be4f799a0ea8968b96ff8d00133bb23b95e4174ad082cb58c73b2bf0f71efbea"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 78 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 65 -> (known after apply)
      ~ urls                = [
          - "https://ee23c16d-open-inspect-slack-bot-codos.opencodos.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.slack_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:29:33Z" -> (known after apply)
      ~ id           = "8bbfc3b8-8d25-47ee-8904-22447bbf8773" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "ee23c16d-82c8-45c5-bf5b-df2b18e0f5a2" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

Plan: 17 to add, 4 to change, 16 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @rhlsthrm

@codos-reviewer codos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: 7 · Non-blocking: 0

The sync introduces regressions in credential availability for retained sandboxes and legacy sessions, allows team automations to be saved or launched without their executor's current authority, and leaves several team-scoped web workflows in incorrect states. Focused control-plane tests passed (164); focused web tests passed (71) after rebuilding shared. GitHub's PR diff endpoint returned 406 for this 326-file PR, so the review used the local main-to-head diff. No prior review threads were present.

Comment thread packages/modal-infra/src/sandbox/launch.py
Comment thread packages/control-plane/src/source-control/session-scope.ts
Comment thread packages/control-plane/src/routes/automation-crud.ts
Comment thread packages/control-plane/src/scheduler/scheduler.ts
Comment thread packages/web/src/hooks/use-resource-teams.ts
Comment thread packages/web/src/hooks/use-automations.ts
Comment thread packages/web/src/hooks/use-session-target-picker.ts
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format ✅
Init ✅
Validate ✅
Tests ✅
Modal module tests ✅

Pushed by: @rhlsthrm, Action: pull_request

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Terraform Plan Results

Status: ✅ Success

Show Plan
terraform_data.sign_in_provider_gate: Refreshing state... [id=b29a3d55-0be5-fb92-10a9-027df10c4b75]
terraform_data.access_control_gate: Refreshing state... [id=2b965617-b42b-4b42-5ea5-a1c3c05f10be]
terraform_data.cloudflare_custom_domain_gate: Refreshing state... [id=09b89c9b-996a-d28b-1f9c-08760a492dac]
local_file.web_app_wrangler_production[0]: Refreshing state... [id=d58ccd8dd2962c70f7cff2ffac9821e9e711af31]
random_password.service_auth_secret_slack_bot: Refreshing state... [id=none]
random_password.service_auth_secret_github_bot: Refreshing state... [id=none]
random_password.image_callback_token_pepper: Refreshing state... [id=none]
random_password.service_auth_secret_web: Refreshing state... [id=none]
random_bytes.provider_accounts_encryption_key: Refreshing state...
module.github_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=e0848d433a4f466cafd4ed5d140aad7d]
cloudflare_queue.github_autofix[0]: Refreshing state... [id=033a23f13783415385b2f8799416c20f]
cloudflare_queue.slack_completion_delivery_dlq[0]: Refreshing state... [id=396865e4939b4160937b2dc9ad5dabe1]
random_password.service_auth_secret_linear_bot: Refreshing state... [id=none]
cloudflare_queue.slack_completion_delivery[0]: Refreshing state... [id=247b1100bac2408684d6a75c1bca0d28]
null_resource.slack_bot_build[0]: Refreshing state... [id=139287417073036493]
cloudflare_queue.image_build_finalization_dlq: Refreshing state... [id=61535c686d8546099cfddcf39833572b]
module.session_index_kv.cloudflare_workers_kv_namespace.this: Refreshing state... [id=ea0a253d5cb64d75a841acb88040cd2f]
cloudflare_queue.github_autofix_dlq[0]: Refreshing state... [id=3a27213aeba149d4b7cbf2d3551842f8]
cloudflare_r2_bucket.media: Refreshing state... [id=open-inspect-media-codos]
null_resource.control_plane_build: Refreshing state... [id=5528656732809257011]
cloudflare_queue.image_build_finalization: Refreshing state... [id=a0647323f7424e778b9d59da50dc55cf]
module.linear_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=777f94c3595f4de680c256a4e5fc6653]
module.slack_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=ab5c371c8bc04a938ff2f71809933aa0]
cloudflare_d1_database.main: Refreshing state... [id=f747a908-5c69-45a1-86ab-ceb5250cf5e0]
data.external.modal_source_hash[0]: Reading...
null_resource.linear_bot_build[0]: Refreshing state... [id=3415715852648161979]
module.modal_app[0].null_resource.modal_secrets[0]: Refreshing state... [id=8477737195823217344]
null_resource.github_bot_build[0]: Refreshing state... [id=6908611508837039030]
null_resource.web_app_cloudflare_build[0]: Refreshing state... [id=3047571768604585709]
module.linear_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=049cd48117bc48b9b4332683a97d0a0e]
data.external.modal_source_hash[0]: Read complete after 0s [id=-]
module.modal_app[0].null_resource.modal_deploy: Refreshing state... [id=8891027293183044333]
module.slack_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=375c2c6875904657bce05c62c8048c76]
module.slack_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=ee23c16d-82c8-45c5-bf5b-df2b18e0f5a2]
module.linear_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=14ed4788-feb0-4b6e-9ceb-0d1c7b47bf4d]
null_resource.d1_migrations: Refreshing state... [id=263751651589333239]
module.linear_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=67610c8f-eb33-4aaa-954b-154744e911d3]
module.slack_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=8bbfc3b8-8d25-47ee-8904-22447bbf8773]
cloudflare_queue_consumer.slack_completion_delivery[0]: Refreshing state...
module.control_plane_worker.cloudflare_worker.this: Refreshing state... [id=3457352971a74b89be5ed3700db48a8e]
module.control_plane_worker.cloudflare_worker_version.this: Refreshing state... [id=b93c0070-0c3d-4ffa-83b8-567b6777d98c]
module.control_plane_worker.cloudflare_workers_deployment.this: Refreshing state... [id=cebefa4e-9f03-4b7e-a545-86f94cab5c9a]
module.control_plane_worker.cloudflare_workers_cron_trigger.this[0]: Refreshing state... [id=open-inspect-control-plane-codos]
null_resource.web_app_cloudflare_deploy[0]: Refreshing state... [id=5183708612434619948]
cloudflare_queue_consumer.image_build_finalization: Refreshing state...
module.github_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=fa832fd890a14336bc3c63305e9bc36f]
null_resource.web_app_cloudflare_secrets[0]: Refreshing state... [id=8981633407656564074]
module.github_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=f7356b06-2065-4b43-9577-f9d17e05d1eb]
module.github_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=624981be-d5b4-467f-a418-465957bfaf1d]
cloudflare_queue_consumer.github_autofix[0]: Refreshing state...

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # local_file.web_app_wrangler_production[0] will be created
  + resource "local_file" "web_app_wrangler_production" {
      + content              = <<-EOT
            name = "open-inspect-web-codos"
            main = ".open-next/worker.js"
            compatibility_date = "2025-08-15"
            compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
            # Keep-names makes esbuild emit __name() calls, which leak into next-themes'
            # inline script and throw in the browser.
            keep_names = false
            
            # A custom-domain deployment has one canonical browser origin.
            workers_dev = true
            
            [vars]
            CONTROL_PLANE_URL = "https://open-inspect-control-plane-codos.opencodos.workers.dev"
            NEXT_PUBLIC_WS_URL = "wss://open-inspect-control-plane-codos.opencodos.workers.dev"
            NEXT_PUBLIC_SANDBOX_PROVIDER = "modal"
            NEXT_PUBLIC_APP_NAME = "Open-Inspect"
            NEXT_PUBLIC_APP_ICON_URL = ""
            
            [assets]
            directory = ".open-next/assets"
            binding = "ASSETS"
            
            [[services]]
            binding = "CONTROL_PLANE_WORKER"
            service = "open-inspect-control-plane-codos"
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "../../..//packages/web/wrangler.production.toml"
      + id                   = (known after apply)
    }

  # null_resource.control_plane_build must be replaced
-/+ resource "null_resource" "control_plane_build" {
      ~ id       = "5528656732809257011" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:31Z" -> (known after apply)
        }
    }

  # null_resource.github_bot_build[0] must be replaced
-/+ resource "null_resource" "github_bot_build" {
      ~ id       = "6908611508837039030" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:30Z" -> (known after apply)
        }
    }

  # null_resource.linear_bot_build[0] must be replaced
-/+ resource "null_resource" "linear_bot_build" {
      ~ id       = "3415715852648161979" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:31Z" -> (known after apply)
        }
    }

  # null_resource.slack_bot_build[0] must be replaced
-/+ resource "null_resource" "slack_bot_build" {
      ~ id       = "139287417073036493" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:30Z" -> (known after apply)
        }
    }

  # null_resource.web_app_cloudflare_build[0] must be replaced
-/+ resource "null_resource" "web_app_cloudflare_build" {
      ~ id       = "3047571768604585709" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:29:30Z" -> (known after apply)
        }
    }

  # null_resource.web_app_cloudflare_deploy[0] must be replaced
-/+ resource "null_resource" "web_app_cloudflare_deploy" {
      ~ id       = "5183708612434619948" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-10-01T08:31:55Z" -> (known after apply)
        }
    }

  # module.control_plane_worker.cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "3457352971a74b89be5ed3700db48a8e"
        name           = "open-inspect-control-plane-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [
              - {
                  - namespace_id   = "34735ba6d2804d67a82cf0bdf5a3175f" -> null
                  - namespace_name = "open-inspect-control-plane-codos_SessionDO" -> null
                  - worker_id      = "3457352971a74b89be5ed3700db48a8e" -> null
                  - worker_name    = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
          ~ queues                       = [
              - {
                  - queue_consumer_id = "4e24da4810c84b3e9e80ea014860d145" -> null
                  - queue_id          = "033a23f13783415385b2f8799416c20f" -> null
                  - queue_name        = "open-inspect-github-autofix-codos" -> null
                },
              - {
                  - queue_consumer_id = "f37fe99c4658470aa36767dfb68c3d6f" -> null
                  - queue_id          = "a0647323f7424e778b9d59da50dc55cf" -> null
                  - queue_name        = "open-inspect-image-build-finalization-codos" -> null
                },
            ] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "7aa4fa7a556a48708d1ebd7bbba3263a" -> null
                  - name = "open-inspect-web-codos" -> null
                },
              - {
                  - id   = "fa832fd890a14336bc3c63305e9bc36f" -> null
                  - name = "open-inspect-github-bot-codos" -> null
                },
              - {
                  - id   = "049cd48117bc48b9b4332683a97d0a0e" -> null
                  - name = "open-inspect-linear-bot-codos" -> null
                },
              - {
                  - id   = "375c2c6875904657bce05c62c8048c76" -> null
                  - name = "open-inspect-slack-bot-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:31:51Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.control_plane_worker.cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:31:53Z" -> (known after apply)
      ~ id                  = "b93c0070-0c3d-4ffa-83b8-567b6777d98c" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      ~ migration_tag       = "v1" -> (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/control-plane/dist/index.js" -> null
              - content_sha256 = "854e8ac71750e38324cf7b66d059d72ca16e62fb6074fd2ecc7f976909621078" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/control-plane/dist/index.js"
              + content_sha256 = "3e33de33bcda44f9bd57d1c64347b2ac7d86f588c4a15276569f984a33c4cd91"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 76 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 91 -> (known after apply)
      ~ urls                = [] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.control_plane_worker.cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:31:55Z" -> (known after apply)
      ~ id           = "cebefa4e-9f03-4b7e-a545-86f94cab5c9a" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "b93c0070-0c3d-4ffa-83b8-567b6777d98c" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.github_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "fa832fd890a14336bc3c63305e9bc36f"
        name           = "open-inspect-github-bot-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "3457352971a74b89be5ed3700db48a8e" -> null
                  - name = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:31:55Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.github_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:31:56Z" -> (known after apply)
      ~ id                  = "f7356b06-2065-4b43-9577-f9d17e05d1eb" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/github-bot/dist/index.js" -> null
              - content_sha256 = "5cdbd14abb2645c367130bc1db746dca2929dc7ea270f57914d1c3cdc084bc44" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/github-bot/dist/index.js"
              + content_sha256 = "23f9979b7b93a642f841ec5954878c060e916f0e5ea6346f182c0c0daf8b8efd"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 74 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 40 -> (known after apply)
      ~ urls                = [
          - "https://f7356b06-open-inspect-github-bot-codos.opencodos.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.github_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:31:57Z" -> (known after apply)
      ~ id           = "624981be-d5b4-467f-a418-465957bfaf1d" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "f7356b06-2065-4b43-9577-f9d17e05d1eb" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "049cd48117bc48b9b4332683a97d0a0e"
        name           = "open-inspect-linear-bot-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "3457352971a74b89be5ed3700db48a8e" -> null
                  - name = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:29:31Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:29:32Z" -> (known after apply)
      ~ id                  = "14ed4788-feb0-4b6e-9ceb-0d1c7b47bf4d" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/linear-bot/dist/index.js" -> null
              - content_sha256 = "896f64892838c78a55e22e96e5362ddd9e6d308f1d8238b5dcbe5cde6d83f593" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/linear-bot/dist/index.js"
              + content_sha256 = "7b42cf70800722f964d7272de95ecc31f5307b71f4a0a0b2d9d8f68aecc38417"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 80 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 31 -> (known after apply)
      ~ urls                = [
          - "https://14ed4788-open-inspect-linear-bot-codos.opencodos.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:29:33Z" -> (known after apply)
      ~ id           = "67610c8f-eb33-4aaa-954b-154744e911d3" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "14ed4788-feb0-4b6e-9ceb-0d1c7b47bf4d" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.modal_app[0].null_resource.modal_deploy must be replaced
-/+ resource "null_resource" "modal_deploy" {
      ~ id       = "8891027293183044333" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "secrets_created"   = (sensitive value)
          ~ "source_hash"       = "2ba85fecd95506fef751e24b3b0ffa9c6c9c26ff72519878575273c24e06a415" -> "320955e4ffa54637ea646b4563c126ffc3c60ca4c4d584a8a83c2476d4386960"
            # (3 unchanged elements hidden)
        }
    }

  # module.modal_app[0].null_resource.modal_secrets[0] must be replaced
-/+ resource "null_resource" "modal_secrets" {
      ~ id       = "8477737195823217344" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "secrets_hash"      = (sensitive value)
            # (1 unchanged element hidden)
        }
    }

  # module.slack_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "375c2c6875904657bce05c62c8048c76"
        name           = "open-inspect-slack-bot-codos"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [
              - {
                  - queue_consumer_id = "767c5dfe751c4852a536d98b836cdd94" -> null
                  - queue_id          = "247b1100bac2408684d6a75c1bca0d28" -> null
                  - queue_name        = "open-inspect-slack-completion-codos" -> null
                },
            ] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "3457352971a74b89be5ed3700db48a8e" -> null
                  - name = "open-inspect-control-plane-codos" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-10-01T08:29:31Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.slack_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-10-01T08:29:32Z" -> (known after apply)
      ~ id                  = "ee23c16d-82c8-45c5-bf5b-df2b18e0f5a2" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/slack-bot/dist/index.js" -> null
              - content_sha256 = "d461e51f22ef13a3c3e7048bad91ed13f450872b4af43e534acce447d7d50a65" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/slack-bot/dist/index.js"
              + content_sha256 = "be4f799a0ea8968b96ff8d00133bb23b95e4174ad082cb58c73b2bf0f71efbea"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 78 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 65 -> (known after apply)
      ~ urls                = [
          - "https://ee23c16d-open-inspect-slack-bot-codos.opencodos.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.slack_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      + author_email = (known after apply)
      ~ created_on   = "2026-10-01T08:29:33Z" -> (known after apply)
      ~ id           = "8bbfc3b8-8d25-47ee-8904-22447bbf8773" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "ee23c16d-82c8-45c5-bf5b-df2b18e0f5a2" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

Plan: 17 to add, 4 to change, 16 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @rhlsthrm

@codos-reviewer codos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: 9 · Non-blocking: 0

The seven previously reported failures remain reproducible at this head and have no replies on their threads. Two additional edit flows now resend unchanged repository selections and reject otherwise valid edits after a grant is revoked. GitHub rejects the 330-file PR diff with HTTP 406, so this review used the local main-to-head diff at the leased commit. I did not rerun the full suites in this pass.

Comment thread packages/modal-infra/src/sandbox/launch.py
Comment thread packages/control-plane/src/source-control/session-scope.ts
Comment thread packages/control-plane/src/routes/automation-crud.ts
Comment thread packages/control-plane/src/scheduler/scheduler.ts
Comment thread packages/web/src/hooks/use-resource-teams.ts
Comment thread packages/web/src/hooks/use-automations.ts
Comment thread packages/web/src/hooks/use-session-target-picker.ts
Comment thread packages/web/src/app/(app)/(sidebar)/automations/[id]/edit/page.tsx
Comment thread packages/web/src/components/settings/environment-form.tsx
@rhlsthrm

rhlsthrm commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Review dispositions (codos-reviewer, 9 blocking)

All nine findings are on upstream-authored code this sync carries: ColeMurray#2178, ColeMurray#2180, ColeMurray#2205 and ColeMurray#2206. For every flagged file, either the file is byte-identical to upstream b8c9a80b (git diff --quiet origin/main main -- <path>), or the flagged lines are outside the fork's own hunks. The fork's hunks in automation-crud.ts and scheduler.ts are the maxConcurrentRuns/accessTokenWrites lines; in the automation edit page it is one line. Patching these in the fork would add divergence, so each finding was reproduced on clean upstream instead. Real defects went upstream as one small PR each.

# Finding Verdict Disposition
T2 Team automation create doesn't check the executor's execution authority Real, narrower and older than reported. Target-use checks already run on create through authorizeSessionTarget. Only sessions.create is missing, and workspace automations have lacked it since ColeMurray#1676/ColeMurray#1678. Reproduced: 201, then paused at the first tick Upstream ColeMurray#2224
T3 Membership removed between admission and launch still starts a team session Real. Reproduced deterministically in 3 interleavings. The launch-time re-read checks only archive status Upstream ColeMurray#2223
T5 Automation list keeps cached pages after a 403/404 revalidation Real. SWR keeps data; the hook test fails on main Upstream ColeMurray#2219
T6 Auto-selected environment stays launchable after a catalog 403/404 Real. The fix also needs a selection-effect guard. Changing only the launch condition causes an infinite re-select loop (confirmed: worker OOM) Upstream ColeMurray#2220
T14 Automation edit resends unchanged repositories, so non-target edits fail after grant revocation Real, broader than reported. It also returns 403 for a manager without repositories.use with grants intact. The comparison has to ignore order (stored order is owner/name) Upstream ColeMurray#2222
T15 Environment edit always resends repositories Real, narrower than worded. Renaming with prebuilds on after revocation is deliberately refused (upstream test). What actually breaks is disabling prebuilds after revocation, which ColeMurray#2205 promises works, plus every save by a lead without repositories.use Upstream ColeMurray#2221
T1 Scalar sessions without session_repositories rows need the catalog cache Mechanism is real; upstream chose it on purpose. ColeMurray#2180's Operator Impact section documents it, and the fallback was added (7988327) then removed (7b2e28c) because only pre-0032 sessions (before 2026-07-07) are affected, outside the 30-day window. Production exposure here: zero. All 5948 repo sessions in D1 have session_repositories rows No change
T0 Restored pre-ColeMurray#2178 snapshots lose VCS_CLONE_TOKEN/gh fallback Does not reproduce. Every runtime that passes the v62 snapshot floor gets git and gh credentials from the control-plane broker whenever CONTROL_PLANE_URL/SANDBOX_AUTH_TOKEN/session id are set, and restore always sets them. The env token is an image-build fallback only. A probe of the v62 helper returned the broker credential with and without the removed env vars. Snapshots from before the credential-helper migration (v51) are already held below the floor No change
T4 Environment team picker omits teams for admin non-members Does not reproduce. useTeams() calls /api/teams, which requests /teams?membership=all (app/api/teams/route.ts:8, pinned by route.test.ts:38). Admin non-members get canManageEnvironments=true, and an existing hook test already covers a non-member team being offered No change

None of these block deploying the fork. T1 and T0 are the deploy-time risks, and production exposure was measured at zero for T1; T0 does not reproduce. The other findings are pre-existing upstream behaviour that the upstream PRs fix.

@rhlsthrm
rhlsthrm merged commit a49b76e into main Oct 2, 2026
26 checks passed
@rhlsthrm
rhlsthrm deleted the sync/upstream-2026-10-02 branch October 2, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants