Skip to content

chore: sync fork with upstream (ColeMurray/background-agents) - #27

Open
jasoncuriano wants to merge 457 commits into
mainfrom
sync-upstream-0c7bc0dd
Open

jasoncuriano wants to merge 457 commits into
mainfrom
sync-upstream-0c7bc0dd

Conversation

@jasoncuriano

Copy link
Copy Markdown
Collaborator

Routine upstream sync per upstream-sync-runbook.md. Brings the fork up to 0c7bc0dd.

  • 456 upstream commits
  • Merged tree is byte-for-byte identical to upstream/main — no fork-local changes
  • package-lock.json untouched (zero version/resolved/integrity changes)

Verified locally before push:

Check Result
npm run build (all packages) pass
npm run typecheck pass
npm test -w @open-inspect/control-plane 5098 passed, 1 skipped / 276 files
Worker dist/index.js × 5 present

Notes:

  • Adds a new packages/docs package.
  • Removes the terraform/modules/opencomputer-infra module — review the Terraform plan before applying.

🤖 Generated with Claude Code

open-inspect Bot and others added 30 commits September 30, 2026 15:30
## Summary

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

- Add `/teams` with membership filters, search, per-user local
favorites, counts, join policy, and capability-gated joining.
- Add team pages with directory-visible headers and Members,
member/admin Overview and Activity, and capability-gated Settings using
the existing team-management components.
- Add member-only team session buckets and visibility-filtered,
cursor-paginated activity; add a team filter to the workspace audit
viewer.
- Add owning-team and visibility metadata, private-session collaborator
management, visibility cascades, and desktop/mobile move dialogs with
join consent, repository-grant errors, owner-access warnings, and retry
without children.
- Use server snapshot capabilities for session actions, retaining the
global permission check only for trace export. Explicitly refresh
snapshots and invalidate mounted, inactive, infinite, and retained list
pages after scope mutations.
- Add BFF proxies, shared response schemas, route-catalog/admission
coverage, and a changelog entry.

## Access And Storage

Directory reads admit active human users. Team work routes require
membership or an administrator role; denied capabilities remain 403 with
`reason_code`, while nonmembers cannot enumerate member-only team feeds.
Existing join-denial codes are preserved.

The collaborator picker has a narrowly scoped `GET
/sessions/:id/collaborator-candidates` reader under always-enforced
`manageCollaborators` admission. It returns only active users' picker
identities; `/members` and its workspace permission remain unchanged.
Invisible sessions return the same 404 as missing sessions before the
candidate handler runs, including in `off` and `shadow`.

Session lists retain the existing `off`/`shadow`/`on` semantics and
private-session restrictions. Scope writes, audit writers, credential
boundaries, and enforcement defaults are unchanged. No D1 or DO
migration is added; the existing Teams schema is migration 0083.

## Checkpoint: Commands And Results

Final checks ran sequentially with targeted test selections. The branch
was rebased from `cf345db` onto current `main` at `3beccb0`, then
affected-package typechecks, scoped lint, SQL portability, and targeted
tests were rerun.

```bash
npm run build -w @open-inspect/shared
npm run typecheck
npm run typecheck -w @open-inspect/control-plane -w @open-inspect/web
npm run lint:sql-portability

git diff --name-only -z origin/main...HEAD -- '*.ts' '*.tsx' | xargs -0 npx eslint
git diff --name-only -z origin/main...HEAD -- '*.ts' '*.tsx' '*.md' | xargs -0 npx prettier --check
git diff --check origin/main...HEAD

git diff --relative=packages/web --name-only -z origin/main...HEAD -- ':(glob)packages/web/src/**/*.test.ts' ':(glob)packages/web/src/**/*.test.tsx' | xargs -0 -r npm test -w @open-inspect/web --

npm test -w @open-inspect/control-plane -- src/routes/teams.test.ts src/routes/audit-events.test.ts src/routes/session-runtime-proxy.test.ts src/db/audit-event-store.test.ts src/router.policy.test.ts

npm run test:integration -w @open-inspect/control-plane -- test/integration/teams-routes.test.ts test/integration/audit-event-store.test.ts test/integration/audit-events-route.test.ts test/integration/session-inbox.test.ts test/integration/session-snapshot.test.ts test/integration/collaborator-candidates.test.ts test/integration/hono-route-catalog-conformance.test.ts test/integration/route-admission-matrix.test.ts

npm test -w @open-inspect/shared -- src/types/server-messages.test.ts
```

- Shared build and all-workspace typecheck passed before rebase;
control-plane and web typechecks passed again after rebase.
- Final web selection: **31 files, 368 tests passed**.
- Final control-plane unit selection: **5 files, 204 tests passed**.
- Final control-plane integration selection: **8 files, 106 tests
passed**.
- Shared snapshot-contract selection: **21 tests passed**.
- Scoped ESLint, Prettier, SQL portability, commit hooks, and diff
checks passed.

Coverage includes a two-team parent/child move with both buckets
updating despite unchanged timestamps, Member collaborator add/remove
without workspace-directory access, all enforcement modes, nonmember
concealment, private audit filtering, missing capabilities,
account/cache isolation, cascade retry, and not-found rendering after
access loss.

### Failures Encountered And Fixed

The tests-first capability/snapshot regression run failed as expected:

```text
Test Files  2 failed (2)
     Tests  4 failed | 2 passed (6)
TypeError: useRefreshSessionSnapshot is not a function
```

The tests-first desktop-action, fresh-SSR, inactive-cache, and
target-membership regression run also failed before the fixes:

```text
Test Files  4 failed (4)
     Tests  4 failed | 51 passed (55)
```

A subsequent targeted web run caught stale expectations after
unconditional cache invalidation; those assertions now require old head
rows and retained pages to disappear while refreshed data is pending:

```text
Test Files  2 failed | 25 passed (27)
     Tests  4 failed | 342 passed (346)
AssertionError: expected [] to deeply equal [ 'attention' ]
AssertionError: expected "vi.fn()" to be called once, but got 2 times
```

The initial collaborator-candidate integration fixture omitted nullable
`ownerTeamId`; it was corrected without changing the store:

```text
Test Files  1 failed | 6 passed (7)
     Tests  12 failed | 80 passed (92)
Error: D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'
```

The first typecheck used a shared subpath that is not exported. Both
imports now use the existing root type export:

```text
src/lib/session-capabilities.test.ts(3,42): error TS2307: Cannot find module '@open-inspect/shared/types/session-access' or its corresponding type declarations.
src/lib/session-capabilities.ts(2,71): error TS2307: Cannot find module '@open-inspect/shared/types/session-access' or its corresponding type declarations.
```

SQL lint caught the candidate sort; it now uses portable
`LOWER(COALESCE(...))`:

```text
SQL portability: 1 disallowed construct(s).
packages/control-plane/src/routes/session-scope.ts:297  collate-nocase  COLLATE NOCASE
```

The first scoped formatting command included generated Vitest snapshots.
They were excluded from subsequent formatting commands:

```text
[error] No parser could be inferred for file "/workspace/background-agents/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap".
[error] No parser could be inferred for file "/workspace/background-agents/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap".
```

## Checkpoint: Baseline Differences

- The HTTP snapshot at `session-runtime-proxy.ts` exposed team,
visibility, collaborators, and capabilities but no canonical owner
identity. It now also decorates `ownerUserId` from the admitted D1 row,
never participant presence, for the owner-access warning.
- The existing workspace candidate reader required
`workspace.members.read`, which built-in Members do not have. The scoped
picker reader solves this without widening the workspace directory.
- The inbox store filtered by teams but omitted the owner/scope fields
needed for effective per-row capabilities. Its internal projection now
preserves those fields; the team route decorates the response.
- SSR snapshots had no client refresh path, and sidebar cursor pages
retained rows outside SWR. The provider now explicitly refetches and
accepts fresh SSR data; successful scope mutations also reset retained
pagination.
- The existing move write requires target membership or explicit joining
of an open team, including for administrators. The dialog follows that
contract instead of changing the write path.

## Checkpoint: Deliberately Deferred

- PR 9's switcher, active-team hook, sidebar/discovery filters, composer
team/visibility fields, transport `sessionGone`, and
`requireTeamOnCreate` response field are untouched. PR 9 is not on the
rebased `main`; its switcher-to-team-page link and switch-teams browser
step remain follow-ups.
- The PR-state board and later repository, channel, secrets, automation,
and environment tabs are not implemented.
- Activity reads `team_id` only. Move-departure audit writers are
unchanged. Team Activity contains domain events only. HTTP authorization
decisions remain in the permission-gated workspace audit; the store does
not inspect route prefixes.
- Screenshots, additional browser recordings, and full-suite reruns were
deferred in favor of sequential targeted checks. The parent/child move,
distinct team lists, collaborator flow, and not-found behavior are
covered by integration and component tests.

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

## Audit Cleanup

Follow-up commit `d91bf24` removes route-prefix matching from
visibility-scoped audit reads. Team Activity filters by resource type
and retains current session visibility and read-permission checks. Its
event-type selector contains operation events only. Workspace audit
still retains HTTP authorization decisions. No schema, migration, or
audit-writer changes.

Sequential validation: 55 targeted control-plane integration tests and
34 web tests passed; control-plane/web typechecks, scoped ESLint,
Prettier, SQL portability, and commit hooks passed. The new regression
coverage was red before the change and verifies path-independent
exclusion, null resource IDs, pagination, and preservation of workspace
evidence.

---------

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

> Follows ColeMurray#2168 (session details tabs, merged). This PR's diff is only
the diff-view changes.

## Summary

Opening a changed file now shows the diff in the main column, where the
conversation was, instead of a resizable panel squeezed between the
conversation and the details sidebar. The sidebar stays where it is, and
its Changes list becomes the file navigator for the diff.

At 1440px with both sidebars open, the old side panel left roughly 400px
for code: Split was unavailable, and the panel repeated the sidebar's
file list in a narrow rail. The diff now gets the whole main column
(about 790px at that width), so Split is available.

## Layout

- **The diff replaces the conversation in the main column.** The
timeline, terminal and composer stay mounted underneath, so scroll
position and an unsent draft survive opening and closing a diff. The
resizable conversation/diff split and its stored layout
(`session-changes-layout-v2`, `useBrowserLayoutStorage`) are removed.
- **The details sidebar stays open while a diff is showing**, and the
header's sidebar toggle stays available (the `showDesktopDetailsToggle`
prop, which only existed to hide it, is removed).
- **The session page owns the inspector tab** (it was private to the
sidebar), so the layout can tell whether the sidebar is showing the file
list. Opening a diff shows the Changes tab without replacing the tab the
viewer remembers; moving between files doesn't touch the tab.
- **The diff's own file list starts hidden while the sidebar shows the
Changes list**, and appears when the sidebar is closed or on another
tab. Once the viewer toggles it, their choice holds while the diff is
open.
- **Closing the diff returns focus** to the row of the file being
viewed, else to whatever opened the diff (such as a file link in the
agent's reply), else to the sidebar toggle. `useSessionDiffSelection`
owns the selection and this focus order.

## Review view

- A top bar with **← Session**, the file count, and the **File list**
toggle.
- A selected-file header with the repository, full path, change summary,
and previous/next with the file's position (`2 / 4`).
- **Split depends on the code column's own width** (at least 640px), not
the whole panel. Falling back to Unified doesn't overwrite a saved Split
preference.
- Switching files scrolls the code back to the top.
- "Compared with session start" moves to a footer, with the base → head
SHAs.
- **Phones** keep the full-screen diff; the file list starts collapsed,
and picking a file closes it and returns focus to the diff.

## Renderer

- 12px code on 20px lines, GitHub light/dark syntax themes, and code
drawn on the app background with the app's added/removed/modified
colors.
- **The first diff opened after a page load could render blank in
development**: mounting the renderer while the shared highlighter
initialized left its shadow root empty under React Strict Mode. The
renderer now loads the highlighter and review themes first and shows the
raw patch in the meantime. The raw patch also stays readable if
highlighting fails to load. Once the review themes are attached, later
diffs mount highlighted immediately; the check uses `areThemesAttached`
because the shared highlighter reports itself loaded before its themes
attach.

## Testing

- `npm test -w @open-inspect/web` (on current `main`, which includes
ColeMurray#2168 and ColeMurray#2164): 256 files, 2,408 tests pass.
- Diff selection tests: opening runs the Changes-tab switch but moving
between files doesn't; focus returns to the current file's row, else the
opener, else the fallback when the sidebar was hidden; rows inside the
closed (inert) mobile sheet are skipped.
- Layout tests: the diff takes the main column with the sidebar visible,
and the workspace and sidebar stay mounted across opening and closing.
- Diff panel tests: the file list's default with and without the
sidebar, the viewer's toggle holding, Split availability by code-column
width without overwriting the saved preference, patch
loading/error/empty/stale states, non-renderable files not being
fetched, previous/next bounds, Escape, the phone list closing with focus
returned, and the list appearing once the sidebar stops showing the
files.
- Renderer tests: options and palette, raw patch until the highlighter
and review themes load, a loaded highlighter still missing the themes,
the failure fallback, and immediate rendering once the themes are
attached.
- `npm run typecheck -w @open-inspect/web`, ESLint, Prettier and `npm
run build -w @open-inspect/web` pass.
- Checked in a browser against sample data: desktop with the sidebar
open and closed, focus after returning to the session, light and dark
themes, and phone width.


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

## Summary by CodeRabbit

* **New Features**
* The Changes view now fills the main workspace when a diff is open,
while the sidebar remains available.
* Inspector tabs stay consistent across desktop, mobile, and session
details views.
* Diff panels include file details, change counts, and previous/next
file navigation.
* **Improvements**
* Split diffs are available on wider screens; narrower layouts use
unified diffs.
* Diff rendering uses light and dark themes, with readable raw-diff
content shown while styling loads or if it fails.
  * Closing a diff restores focus to an appropriate file or control.

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

## Summary

Fixes COL-252.

- Add a small test-only helper module for `Sandbox.exec`,
`Sandbox.create`, and `Sandbox.snapshot_filesystem`, using request
messages from the installed `modal_proto` package and the SDK's own
CPU/memory resource converter.
- Bind create and exec arguments to the installed SDK signatures before
serialization, rejecting unknown keywords even when SDK methods are
mocked.
- Apply the helpers to recorded fake arguments at all six call sites in
snapshot management, session launch, access-password recovery, and
image-build sessions.
- Extend existing test matrices for timeout budgets, fractional
preparation time, default/custom resources, image sources, tunnel-port
filtering, and enabled access services.
- Add direct helper tests covering float rejection for integer timeout
fields, resource serialization, and float acceptance for snapshot
timeouts.

No production code, dependency pins, migrations, or persisted fields
changed. The already-merged snapshot hotfix remains intact.

## SDK Detail

The installed Modal 1.4.3 protobuf declares
`SandboxSnapshotFsRequest.timeout` as `TYPE_FLOAT`. The legacy snapshot
path therefore accepts fractional timeouts too, while the command-router
path converts its separately supplied timeout with `float(timeout)`. The
helpers follow those actual SDK contracts rather than impose an
integer-only snapshot check. Each recorded snapshot timeout is checked
against both internal contracts in one helper call, without a synthetic
provider-mode flag or duplicate call-site tests.

## Verification

From `packages/modal-infra`:

- `uv run pytest tests/ -q`: 489 passed after removing redundant
snapshot-mode cases.
- `uv run ruff check`: passed.
- `uv run ruff format --check`: passed.
- `git diff --check`: passed.

Temporary mutation checks both failed at the helper with `TypeError:
'float' object cannot be interpreted as an integer`:

- Removed `int()` from the Docker-preparation exec timeout in
`take_snapshot`.
- Passed a deliberately float timeout through the existing
session-create behavior test.

Both mutations were removed before final validation and are not included
in this PR. No live Modal calls were required.

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

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

* **Tests**
* Expanded validation of sandbox creation, execution, resource settings,
and filesystem snapshots across launch configurations.
* Added coverage for default, integer, and fractional CPU settings;
memory options; and execution and snapshot timeout boundaries.
* Verified that launch requests preserve configured options and that
access credentials are recovered only for enabled services.
<!-- 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#2165)

## Summary

- Make Better Auth the sole source of participant OAuth credentials.
Remove copied-token fallback, local refresh/decryption, the unused
legacy refresh helper, and participant token updater.
- Separate a child participant's canonical credential identity from
inherited session ownership. Unresolved prompt authors remain
unresolved, while private children retain their owner and collaborators.
- Enrich browser WebSocket joins/reconnects from the verified canonical
GitHub account, and scheduled/Slack automation prompts from the actual
execution author, preserving Git commit attribution.

## Test-first verification

Added regression tests before production changes and observed failures
for:

- Stale, refresh-only, and unexpired copied credentials bypassing
current Better Auth credentials; copied tokens also bypassing
missing-grant and identity-integrity handling.
- Private children substituting the parent's owner for unresolved
reviewers, including missing SCM identity.
- Actual browser WebSocket prompts dispatching `agent-only` instead of
the linked user's Git identity on join and reconnect.
- Scheduled automation and Slack follow-up prompts omitting the
execution author's GitHub attribution.

All are now green. External GitHub publication/profile calls are mocked;
the browser dispatch and child creation tests exercise real workerd/D1
paths.

## Review hardening

- Require an explicit nullable participant credential identity; never
infer it from session ownership.
- Settle one attribution snapshot after invocation admission. Ambiguity
or unavailable optional profiles omit attribution; storage/integrity
errors fail without routing Slack replies into owner-authored new runs.
- Obtain verified profiles when cached logins are missing, reject
caller-supplied GitHub attribution, and replace stale SCM fields on
unlink/relink.
- Remove legacy OAuth token inputs and writes from init, WS minting, and
participant persistence; retain historical readable columns only.

- Treat supplied prompt enrichment as an authoritative snapshot.
Successive Slack turns clear old attribution after unlinking, ambiguity,
missing login, or optional grant failure.
- Bound optional GitHub attribution for WS joins to five seconds, below
the browser proxy deadline; delayed failures do not block minting or
overwrite the empty snapshot later.

Added regressions first and observed red for these failure paths before
implementing fixes. All are now green.

## Validation

- Shared build
- Control-plane unit suite: **335 files, 5,559 tests passed**
(`--maxWorkers=1`)
- Targeted workerd integration/conformance suites: **11 files, 199 tests
passed** (PR creation, WebSocket participants, child
spawning/operations, scheduler, Slack events, invocation fan-out,
session-core conformance, provider auth, internal routes, and prompt
queueing)
- Control-plane typechecks: Worker, Node, unit/conformance, and
integration configurations
- Control-plane Worker and Node builds
- Changed-file ESLint, Prettier, and `git diff --check`

## Scope

No schema migration, backfill, or production deployment. Historical
SQLite credential fields remain readable but are ignored for
authentication and are no longer written by participant producers. App
fallback remains available when the prompting author genuinely has no
usable Better Auth grant; credential identity mismatches remain
fail-closed.


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

* **New Features**
* Session, automation, and Slack prompts can include verified GitHub
identity details, such as login, name, email, and SCM user ID, when
available.
* Child sessions retain the prompt author’s canonical identity
separately from session ownership.
* Pull request creation uses current browser OAuth credentials across
different cached-credential states.
* Browser Git attribution is available after joining or reconnecting,
and is omitted when an account is unlinked or lacks a login.

* **Changes**
* SCM tokens are no longer stored with participants or refreshed locally
for pull request authorization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…urray#2171)

## Summary
- Replace the native session team and visibility dropdowns in the
new-session composer with the existing shared Radix Select component.
- Keep compact borderless triggers and open menus above the composer,
consistent with adjacent controls.
- Preserve Workspace-to-null team mapping, team visibility availability,
defaults, and loading/creation disabled states.
- Update regression tests to exercise the real custom menus, including
returning to Workspace and disabled/required-team behavior.

## Validation
- Home/sidebar tests: 10 files, 96 tests passed (`npm test -w
@open-inspect/web -- 'src/app/(app)/(sidebar)/' --maxWorkers=1`).
- Web typecheck passed (`npm run typecheck -w @open-inspect/web`).
- Targeted ESLint, Prettier, and `git diff --check` passed.
- Browser-verified both menus at desktop 1440x900 and mobile 390x844
using mocked API responses. Checked keyboard selection back to Workspace
and no mobile horizontal overflow.

## Visual Evidence
Viewport screenshots from `http://localhost:3000` are uploaded to the
session:
- Desktop team menu: `1aa99eb4907cf6204641825aca797958`
- Desktop visibility menu: `5b254d0fc3767825e8c9af721d60741e`
- Mobile visibility menu: `5388f1d6ae602bc7fc52329787bb32ac`
- Mobile team menu: `22af0c5baddd485d346e6605b5c1df7e`

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

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

* **New Features**
* Added session access controls for choosing Private, Team, or Workspace
visibility and selecting a team.
* Team selection supports a “No team” option when allowed; selecting
Team visibility requires a team.
* **Improvements**
* Changing teams preserves the draft’s visibility. Workspace visibility
clears the active team.
* Access controls are disabled while teams are loading, and keyboard
navigation is supported.
<!-- 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
- Adopt the selected inline visibility layout: label and shared
component dropdown on one row, with the child-session checkbox and a
compact ghost-style Save action below.
- Replace the native select with the existing Radix-based Select
component.
- Preserve explicit confirmation, unavailable visibility options,
owner-membership warnings, pending states, and retry-without-children
behavior.
- Keep the implementation in the existing control; remove all temporary
design variations and preview code.

## Verification
- `npm test -w @open-inspect/web --
src/components/session-controls.test.tsx
src/components/session-right-sidebar.test.tsx --maxWorkers=1` (74 tests
passed)
- `npm run typecheck -w @open-inspect/web`
- ESLint and Prettier checks on all changed files
- `git diff --check`
- Browser verification of the actual control in a temporary development
harness: keyboard dropdown selection, disabled unavailable options, and
no horizontal overflow at 390x844 and 1440x960. The harness is not
included in this PR.

## Regression Coverage
- Selecting visibility or toggling the child-session checkbox does not
mutate permissions before Save.
- All controls remain disabled until the updated session snapshot
finishes refreshing, preventing duplicate requests.

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

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

* **User Interface**
* Replaced the visibility dropdown with a shared styled selector and
reorganized the visibility and save controls. The save button is now
labeled “Save.”
* Pressing Escape closes the visibility dropdown first; pressing it
again closes the dialog. If another action has already prevented Escape,
the dialog stays open.
* Visibility options and controls remain unavailable when required
capabilities or team context are missing. When Escape closes the dialog,
focus returns to its previous location.
<!-- 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>
…ies (ColeMurray#2174)

## Summary

Closes
[COL-253](https://linear.app/colemurray/issue/COL-253/teams-follow-up-control-plane-stop-auditing-allowed-team-reads).

- Default `requireTeam` allowed auditing to false for `read` and
`member`, with an optional override. Preserve allowed auditing for
capability needs and explicitly for membership deletion.
- Disable allowed-decision auditing for team-session and
collaborator-candidate reads while retaining their existing
authorization requirements. Directory, member, and activity reads
inherit the quiet defaults.
- Resolve memberships at WebSocket subscribe in every enforcement mode
so non-owner team leads retain move and visibility capabilities.
Collaborator reads and per-command scope-read short-circuiting remain
unchanged.
- Add request-correlated audit regression tests, all-mode subscribe
capability/query-count coverage, and route-policy assertions. Update the
catalog and admission-matrix snapshots and operator changelog.
- Narrow the existing `requireSession` and `requireAll` return
annotations to their actual `active-user` variant, permitting typed
policy overrides without casts or runtime changes.

No migration, access-decision change, credential expansion, new route,
or response-schema change. The route catalog remains 202 routes / 154
paths.

## Checkpoint Report

### 1. Commands And Results

Heavy validation ran sequentially with one Vitest worker and 600000 ms
shell timeouts.

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed before dependent
checks. |
| `npm test -w @open-inspect/control-plane -- src/router.policy.test.ts
src/session/connection-authenticator.test.ts --maxWorkers=1` | Red
phase: 8 failed, 171 passed. After implementation: all 179 passed. |
| `npm run test:integration -w @open-inspect/control-plane --
test/integration/teams-routes.test.ts
test/integration/session-scope-routes.test.ts
test/integration/websocket-session-access.test.ts --maxWorkers=1 -t
'does not audit an allowed team read\|audits an allowed team capability
write\|still audits\|does not audit allowed
collaborator-candidate\|loads memberships at subscribe\|sends move and
visibility'` | Red phase: 10 failed, 4 passed, 80 skipped. Nine failures
reproduced the requested defects; one exposed an incorrect initial
expectation about an existing concealed-team 404, described below. |
| `npm run test:integration -w @open-inspect/control-plane --
test/integration/teams-routes.test.ts
test/integration/session-scope-routes.test.ts
test/integration/websocket-session-access.test.ts
test/integration/hono-route-catalog-conformance.test.ts
test/integration/route-admission-matrix.test.ts --maxWorkers=1 --update
--silent` | All 108 passed; catalog snapshot updated. |
| `npm run test:integration -w @open-inspect/control-plane --
test/integration/route-admission-matrix.test.ts --maxWorkers=1 --update
--silent` | All 13 passed; matrix snapshot updated to record audit
policy alongside unchanged statuses. |
| `npm run typecheck` | Initial run failed on overly broad helper return
types; narrowed those annotations, then all workspaces passed. |
| `npm run lint:fix` | Passed. |
| `npm run lint:sql-portability` | Passed: clean, with 24 existing
baselined occurrences across four files. |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1 --silent` |
338 files passed; 5,645 tests passed. |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1 --silent` | 127 files passed; 1,633 tests passed and one
skipped. |
| `npx prettier --write` on the changed TypeScript files and
`CHANGELOG.md` | Passed; only touched files formatted. Commit hooks also
passed ESLint and Prettier. |
| `npm run test:integration -w @open-inspect/control-plane --
test/integration/teams-routes.test.ts
test/integration/session-scope-routes.test.ts
test/integration/websocket-session-access.test.ts --maxWorkers=1
--silent` | Final post-format check: all 94 passed. |
| `git diff --check` | Passed. |

<details>
<summary>Failure excerpts, verbatim</summary>

Unit red-phase assertions:

```text
-     "auditAllowed": false,
+     "auditAllowed": true,

AssertionError: expected 1st "vi.fn()" call to have been called with [ 'member-user', …(1) ]

- Expected
+ Received

  [
    "member-user",
-   {
-     "includeMemberships": true,
-   },
  ]

 Test Files  2 failed (2)
      Tests  8 failed | 171 passed (179)
```

Integration red-phase assertions:

```text
AssertionError: expected [ Array(1) ] to deeply equal []

- Expected
+ Received

- []
+ [
+   {
+     "action": "authorization.request_allowed",
+   },
+ ]

AssertionError: expected "listForUser" to be called 1 times, but got 0 times

- Expected
+ Received

  {
    "session": {
      "capabilities": {
-       "canChangeVisibility": true,
+       "canChangeVisibility": false,
        "canManageCollaborators": false,
-       "canMove": true,
+       "canMove": false,
      },
    },
  }

AssertionError: expected [] to deeply equal [ Array(1) ]

- Expected
+ Received

- [
-   {
-     "action": "authorization.request_denied",
-   },
- ]
+ []

 Test Files  3 failed (3)
      Tests  10 failed | 4 passed | 80 skipped (94)
```

Initial typecheck failure:

```text
src/routes/session-scope.ts(313,5): error TS2322: Type '{ auditAllowed: false; kind: "none"; } | { auditAllowed: false; kind: "authenticated"; } | { auditAllowed: false; kind: "active-self"; } | { auditAllowed: false; kind: "active-global"; service: ServiceAuthorization; } | { ...; } | { ...; }' is not assignable to type 'RouteAuthorization'.
  Type '{ auditAllowed: false; kind: "service"; services: readonly BotServiceName[]; actor: "required" | "optional"; }' is not assignable to type 'RouteAuthorization'.
    Type '{ auditAllowed: false; kind: "service"; services: readonly BotServiceName[]; actor: "required" | "optional"; }' is not assignable to type '{ kind: "none"; auditAllowed: false; } | { kind: "authenticated"; auditAllowed: false; } | { kind: "active-self"; auditAllowed: boolean; } | { kind: "active-global"; service: ServiceAuthorization; auditAllowed: boolean; } | { ...; }'.
      Type '{ auditAllowed: false; kind: "service"; services: readonly BotServiceName[]; actor: "optional" | "required"; }' is missing the following properties from type '{ kind: "active-user"; allOf: readonly RouteAuthorizationRequirement[]; service: ServiceAuthorization; auditAllowed: boolean; }': allOf, service
src/routes/teams.ts(517,7): error TS2322: Type '{ service: { kind: "deny"; }; auditAllowed: false; kind: "none"; } | { service: { kind: "deny"; }; auditAllowed: false; kind: "authenticated"; } | { service: { kind: "deny"; }; auditAllowed: false; kind: "active-self"; } | { ...; } | { ...; } | { ...; }' is not assignable to type 'RouteAuthorization'.
  Object literal may only specify known properties, and 'service' does not exist in type '{ kind: "none"; auditAllowed: false; }'.
```

</details>

The full integration run emitted workerd diagnostics from deliberate
eviction/error-path tests and NDJSON warnings, but exited successfully
with the totals above.

### 2. Verified Facts And Drift

Branched from `main` at `3b0b575`; fetched `origin/main` again before
commit, with no newer base changes and no repository-grant routes
merged.

- Confirmed the reported causes at
`packages/control-plane/src/routes/shared.ts:187-196`,
`routes/teams.ts:496-520`, `routes/session-scope.ts:313-316`,
`session/components.ts:847-862`, and
`session/connection-authenticator.ts:385,450-458` on the base commit.
- Confirmed migration `0083_teams.sql`, DO schema migration 56, and the
existing route counts. This PR leaves them unchanged.
- Confirmed that `off` and `shadow` use legacy non-private WebSocket
access decisions, while `on` uses scoped decisions; private access
remains scoped in every mode. Only subscribe capability enrichment
changes.
- One existing audit gap differs from the blanket denial-auditing
premise: `packages/control-plane/src/routing/route-admission.ts:626`
returns a concealed-team 404 without authorization-decision evidence.
This PR does not alter that admission path. The denial regression
instead checks the existing explicit team-capability 403, and the
collaborator-candidate regression checks its action-denied 403; both
retain audit rows.

### 3. Deliberately Left Out

- Repository-grant routes and later team work: outside this fix;
existing team routes only.
- The pre-existing concealed-team 404 audit gap: separate admission
behavior, documented above rather than widening this patch.
- Per-command membership/collaborator enrichment in `off` and `shadow`:
intentionally stays short-circuited, with query-count regressions.
- Web, bot, sandbox-runtime, and schema changes: unnecessary for these
control-plane fixes. All-workspace typechecking still passed.

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

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

* **Bug Fixes**
* Permitted reads of team directories, members, sessions, activity, and
collaborator candidates no longer generate authorization audit entries.
Denied requests remain auditable, as do capability changes and team
departures.
* Team leads retain their move and visibility controls in live sessions
across all enforcement modes.
* Members cannot remove other members unless they have member-management
permissions; denied removal attempts are audited.
<!-- 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

Fixes https://linear.app/colemurray/issue/COL-254

- Default navigation to **All my teams**, preserve explicit
Workspace/team choices, and show the switcher for a single active
membership. Users without active teams keep unfiltered lists.
- Keep composer team and visibility draft-local, including required-team
preselection and reconciliation when a local team becomes unavailable.
Composer changes never update sidebar context.
- Retain the last successful membership response after revalidation
failures. First-load failures still block readiness.
- Retire superseded warm drafts without eagerly creating replacements on
configuration changes; warming happens on first input or submit.
- Clear/revalidate list, team, activity, audit, and infinite-list caches
after scope mutations. Revalidate memberships without clearing them;
leave sandbox access and other per-session resources intact. Snapshots
still refresh explicitly.
- Disable unchanged visibility mutations and require confirmation before
non-private child cascades. Populate audit filters from the all-team
directory.
- Update authorization documentation and the changelog.

## Checkpoint Report

### Commands and Results

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run typecheck` | Passed across all workspaces |
| `npm run lint:fix` | Passed |
| `npm run lint:sql-portability` | Passed; 24 existing baselined
occurrences across 4 files |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 338
files passed; 5,642 tests passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | 127 files passed; 1,622 tests passed, 1 skipped |
| `npm test -w @open-inspect/web -- --maxWorkers=1` | Final run: 257
files passed; 2,497 tests passed |
| `git diff --name-only -z \| xargs -0 npx prettier --check` | Passed |
| `git diff --check origin/main...HEAD` | Passed |

Targeted validation also passed:

```bash
npm test -w @open-inspect/web -- src/hooks/use-active-team.test.tsx src/hooks/use-teams.test.tsx src/components/team-switcher.test.tsx 'src/app/(app)/(sidebar)/page-team-context.test.tsx' 'src/app/(app)/(sidebar)/page.test.tsx' src/lib/session-scope.test.ts src/lib/session-scope-refresh.test.tsx src/components/session-controls.test.tsx src/components/settings/audit-log-settings.test.tsx --maxWorkers=1
```

Result: 9 files passed; 242 tests passed. After the additional
unavailable-local-team regression and disabled-directory test, the
focused three-file run passed all 49 tests:

```bash
npm test -w @open-inspect/web -- 'src/app/(app)/(sidebar)/page-team-context.test.tsx' src/hooks/use-active-team.test.tsx src/hooks/use-teams.test.tsx --maxWorkers=1
```

Expected red runs before implementing the context and composer changes:

```bash
npm test -w @open-inspect/web -- src/hooks/use-active-team.test.tsx src/components/team-switcher.test.tsx --maxWorkers=1
```

```text
 Test Files  2 failed (2)
      Tests  14 failed | 14 passed (28)
```

Representative failure:

```text
AssertionError: expected "vi.fn()" to be called with arguments: [ '/api/sessions/inbox' ]

Received:

  1st vi.fn() call:

  [
-   "/api/sessions/inbox",
+   "/api/sessions/inbox?scope=workspace",
  ]


Number of calls: 1
```

```bash
npm test -w @open-inspect/web -- 'src/app/(app)/(sidebar)/page-team-context.test.tsx' --maxWorkers=1
```

```text
 Test Files  1 failed (1)
      Tests  8 failed | 6 passed (14)
```

Representative eager-warm failure:

```text
AssertionError: expected [ [ '/api/sessions', …(1) ], …(1) ] to have a length of 1 but got 2

- Expected
+ Received

- 1
+ 2
```

The first full web run caught a sequencing mistake in the new
unavailable-local-team test, not an implementation failure. The helper
clicked a popover trigger while the popover was already open. Closing it
before invoking the helper fixed the test; the subsequent focused and
full runs passed.

```text
 FAIL  src/app/(app)/(sidebar)/page-team-context.test.tsx > Home team context > falls back locally when the composer's team is no longer an active membership
TestingLibraryElementError: Unable to find an accessible element with the role "radio" and name "Engineering team"

 Test Files  1 failed | 256 passed (257)
      Tests  1 failed | 2496 passed (2497)
```

Browser verification used the actual local Next.js application with
intercepted fixture API responses at `http://localhost:3000`, at desktop
1440x1000 and mobile 390x844. Verified the single-team selector,
team-owned recent session, required-team local preselection, private
composer visibility, and model changes producing one archive and no
eager replacement. Viewport screenshots were captured and uploaded. Real
SWR tests additionally verify that a terminal-like child remains mounted
while membership refresh is pending or fails, and that per-session
resources do not refetch.

### Baseline Facts and Drift

Based on `main` at `3b0b575`; `origin/main` was checked again before
committing and had not advanced.

Confirmed baseline behavior at
`packages/web/src/hooks/use-active-team.ts:36-60`,
`components/team-switcher.tsx:14-18`, `hooks/use-teams.ts:71-81`,
`app/(app)/(sidebar)/page.tsx:109-144,294-297`, and
`lib/session-scope.ts:23-35,89-105`. The requested failure modes were
present.

The composer UI had advanced in ColeMurray#2171: it now uses
`SessionAccessSelector` and shared dropdowns instead of the earlier
standalone select. This PR preserves that UI and changes its state
wiring and regression tests.

The existing `/api/teams` proxy already requests
`/teams?membership=all&includeArchived=true`, so the audit viewer reuses
`useTeams` with an enabled flag rather than adding another API path.
Directory, activity, and collaborator documentation was verified against
existing server routes.

Latest schema remains D1 `0083_teams.sql` and DO migration 56. No
migration is added. Enforcement configuration and all access seams are
unchanged; the web fixes work with `off`, `shadow`, and `on`.

### Deliberately Excluded

Repository grants, target-picker and warm-draft hook internals,
team-page changes, server authorization, schema, credential scope,
email-address display policy, and enforcement defaults are untouched.
These fixes use existing capabilities and APIs. Live remote sandboxes
and terminal connections were not exercised by browser fixtures; cache
continuity is covered by real SWR regression tests.

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

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

* **New Features**
  * The team selector is available when you have a single active team.
* Choose a team for a new session without changing the active sidebar
team.
* Choose a team and visibility for a new session without changing your
active team selection.

* **Bug Fixes**
* Changing visibility for child sessions now requires confirmation when
the change would make them non-private; changes to private visibility
apply without confirmation.
* Team and session lists refresh more reliably after access or scope
changes, while active session tools remain available during membership
refreshes.
* Temporary membership refresh failures no longer clear previously
loaded team data.
* Audit logs can be filtered by teams beyond your own memberships,
including archived teams.
<!-- 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>
…oleMurray#2070)

## Summary
- add a root `overrides` entry pinning `sharp` to `^0.35.4`, alongside
the existing `minimatch` and `undici` overrides
- `miniflare` (via `wrangler`) pins `"sharp": "0.34.5"` exactly, so the
hoisted `node_modules/sharp` was 0.34.5 while `next` already carried its
own nested `sharp@0.35.4`
- with the override, both consumers dedupe to a single hoisted
`sharp@0.35.4` (bundled libvips 8.18.6), and the nested
`next/node_modules/sharp` tree is removed
- resolves two high-severity advisories against the 0.34.5 copy:
-
[GHSA-f88m-g3jw-g9cj](GHSA-f88m-g3jw-g9cj):
inherited libvips vulnerabilities (CVE-2026-33327, CVE-2026-33328,
CVE-2026-35590, CVE-2026-35591), patched in 0.35.0
-
[GHSA-rgj7-g3m4-5g8c](GHSA-rgj7-g3m4-5g8c):
libheif vulnerabilities, patched in 0.35.4

## Why an override instead of a wrangler bump
The newest `wrangler` (4.140.0) depends on
`miniflare@5.20260923.0-alpha`, so a wrangler bump would pull an alpha
miniflare into the local dev and integration-test runtime. The override
leaves `wrangler`/`miniflare` where they are and only moves the image
library. miniflare lazily imports `sharp` only to emulate the Cloudflare
Images binding locally, and no worker in this repo configures that
binding.

## Lockfile
The lockfile was regenerated with npm 11.10.0 so no unrelated entries
change. A semantic comparison of `packages` shows only `sharp`,
`sharp/node_modules/semver`, and `@img/*` entries changed, plus removal
of the now-duplicate `next/node_modules/sharp`,
`next/node_modules/@img/*`, `next/node_modules/semver`, and unused
`@img/sharp-wasm32` entries.

## Verification
- `npm ci`
- `npm ls sharp` resolves a single `sharp@0.35.4` for both `next` and
`miniflare` (deduped)
- `require('sharp')` loads the native binding (vips 8.18.6) and encodes
a PNG
- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/web` (212 files / 1903 tests)
- `npm run test:integration -w @open-inspect/control-plane` (runs in
workerd via miniflare)


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

## Summary by CodeRabbit

* **Chores**
  * Updated an internal package version override.

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

**Summary**
- Include team-directory and collaborator-candidate emails only for
viewers with `workspace.members.read`. Restricted SQL projections return
`NULL AS email` and do not sort by email.
- Render names and avatars, with neutral short-ID labels for unnamed
users. Both identity pickers share trimmed-name /
response-authorized-email / neutral-fallback typeahead.
- Remove the member-facing team Activity tab and `GET
/teams/:id/activity`, including the web proxy, component,
response-schema aliases, and activity-only audit-store/hook options.
- Keep all team/session audit writes, stored audit history, and the
workspace audit API/UI behind `workspace.audit.read`, including team
filtering. New membership audits contain membership fields rather than
response profile fields.
- Keep privacy route tests focused and update the canonical route
catalog to 201 routes / 153 paths. Update auth documentation and add a
removal changelog entry.

Fixes [COL-255](https://linear.app/colemurray/issue/COL-255).

**Scope Decision**
The generic team audit feed was removed because its product purpose is
unclear and exposing raw audit metadata to ordinary team members creates
an unnecessary privacy boundary. Audit events remain recorded and
available through the permission-gated workspace audit viewer. The
removed endpoint returns 404 for Members, Administrators, and Owners in
every enforcement mode. No audit history is deleted, and no migration is
required.

**Checkpoint Report**
**Validation**

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run typecheck` | Passed across all workspaces |
| `npm run lint:fix` | Passed |
| `npm run lint:sql-portability` | Clean; existing baseline unchanged |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 339
files, 5,651 tests passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | 128 files passed; 1,646 tests passed, 1 skipped |
| `npm test -w @open-inspect/web -- --maxWorkers=1` | 257 files, 2,576
tests passed |
| `npx prettier --check` on changed source/test/docs files | Passed |
| `git diff --check` and staged diff checks | Passed |

Heavy suites ran sequentially with 600000 ms timeouts. The
route-catalog/admission snapshots were regenerated after removing
exactly one route; the manifest fixture indices shift accordingly.

Regression tests were added before the removal. Initial targeted results
were:

```text
 Test Files  1 failed (1)
      Tests  1 failed | 136 skipped (137)
```

The policy test found the still-registered activity route.

```text
 Test Files  1 failed (1)
      Tests  3 failed | 12 skipped (15)
AssertionError: expected 200 to be 404 // Object.is equality
```

The endpoint-absence checks failed in `off`, `shadow`, and `on` before
implementation.

```text
 Test Files  1 failed (1)
      Tests  3 failed | 17 skipped (20)
```

The new UI assertions found the Activity tab for a team member,
Administrator, and Owner before removal.

A preservation test initially counted an extra default-role audit event
generated when its fixture inserted a user:

```text
 Test Files  1 failed | 5 passed (6)
      Tests  1 failed | 85 passed (86)
```

The test query was scoped to its team so it verifies retained
HTTP/private-session/history evidence and team filtering without
counting unrelated workspace bootstrap events. The final focused
integration run passed all 86 tests; the complete suites above also
passed.

**Visual Verification**
Verified real TeamPage, Members, Overview, Settings, and workspace Audit
log components in Chromium fixtures at desktop 1280x900 and mobile
390x844. Confirmed no Activity tab or team-activity requests,
permission-appropriate remaining tabs/actions, redacted member emails,
workspace audit permission/team filtering, and no horizontal overflow.
Screenshots were uploaded. These checks used synthetic auth/data and
mocked APIs in an isolated Vite harness, not a production-authenticated
end-to-end test.

**Facts And Exclusions**
The branch includes the team audit-admission and web team-context
updates merged from `main`, with conflict resolutions preserving both
changes. Removing activity intentionally changes the catalog from 202
routes / 154 paths to 201 / 153. Latest schema assumptions remain D1
migration 0083 and DO migration 56; neither was changed. The new
upstream dependency-only commit does not conflict with this branch.

No new write paths, credential changes, repository-grant work, session
creation changes, database backfill, or migrations. Team sessions,
generic session/autofix activity, audit operation names, workspace audit
team filtering, and all stored audit history remain. Historical
changelog entries are retained, with a new entry documenting the
removal.

---------

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

## Summary

- Require current owning-team membership for every user action except
`read` on a team-owned session, including for workspace Owners and
Administrators. Enforce the full action resolver in `off`, `shadow`, and
`on` through one shared rollout predicate across HTTP admission,
WebSocket commands, snapshot capabilities, and sandbox URL redaction.
- Preserve existing visibility reads, workspace-owned behavior, private
break-glass auditing, and read-authorized collaborator self-removal.
- Remove session ownership moves end to end: control-plane route and
store methods, web BFF/dialog/buttons, and
session/automation/environment `move` actions and `canMove`
capabilities. Retain historical `session.moved` audit registration and
its viewer label, but remove it from the session audit write union.
- Update authorization documentation, the changelog, fixtures, route
counts, and catalog/admission snapshots. Visibility changes and their
cascade remain intact.

This closes the interaction boundary that could expose a team's sandbox
credentials to nonmembers, and removes ownership changes that could
leave a running sandbox carrying credentials from its previous team.

Issue:
[COL-257](https://linear.app/colemurray/issue/COL-257/teams-follow-up-shared-control-plane-web-only-team-members-act-on-team)

## Checkpoint Report

### Commands and Results

Final validation was run sequentially after rebasing onto `main` at
`3789205`:

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run typecheck` | Passed across all TypeScript workspaces,
including control-plane unit/integration configurations |
| `npm run lint:fix` | Passed |
| `npm run lint:sql-portability` | Passed; no new violations |
| `npm test -w @open-inspect/shared -- --maxWorkers=1 --silent` | 64
files passed; 1,079 tests passed |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1 --silent` |
339 files passed; 5,690 tests passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1 --silent` | 128 files passed; 1,644 tests passed, 1
skipped |
| `npm test -w @open-inspect/web -- --maxWorkers=1 --silent` | 256 files
passed; 2,545 tests passed |
| `git diff --check origin/main...HEAD` | Passed |

The integration suite prints its fault-injection diagnostics for forced
DO eviction and malformed D1 input, plus NDJSON body warnings. The web
suite prints a timer warning and jsdom navigation diagnostic. All final
commands exited successfully.

Regression coverage includes all session actions/visibilities/roles in
the pure resolver; HTTP prompt and sandbox admission for nonmember
Members, Administrators, Owners, and session creators; WebSocket
prompt/typing before membership, after joining, and after removal;
private removed owners; collaborator self-removal; false capabilities
and redacted sandbox URLs in every mode; and 404 for the deleted scope
route.

Additional development commands:

- `npm test -w @open-inspect/shared -- src/types/session-access.test.ts
--maxWorkers=1`: red, then green with all 32 tests passing before
runtime wiring.
- `npm test -w @open-inspect/control-plane --
src/authorization/teams-enforcement.test.ts
src/session/connection-authenticator.test.ts
src/routes/session-runtime-proxy.test.ts --maxWorkers=1`: red before
wiring; one subsequent fixture failure; then green with all 131 tests
passing.
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/session-access-routes.test.ts
test/integration/websocket-session-access.test.ts
test/integration/session-scope-routes.test.ts --maxWorkers=1`: six
fixture/expectation failures, corrected before the full green run.
- `npm test -w @open-inspect/control-plane -- --maxWorkers=1`: initially
26 failures in the child-spawn suite because its partial mocked
workspace row omitted `ownerTeamId` and `visibility`. The fixture now
supplies those fields; no production child-creation path changed.
- `npm test -w @open-inspect/control-plane --
src/router.spawn-child.test.ts --maxWorkers=1 --silent`: passed after
correcting that fixture.
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/session-access-routes.test.ts
test/integration/websocket-session-access.test.ts
test/integration/session-scope-routes.test.ts
test/integration/teams-routes.test.ts
test/integration/hono-route-catalog-conformance.test.ts
test/integration/route-admission-matrix.test.ts --maxWorkers=1 --silent
-u`: wrote the catalog update; three sandbox-readiness fixture failures,
then corrected by seeding a ready test sandbox and checking sandbox
access before prompting.
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/session-access-routes.test.ts --maxWorkers=1 --silent`:
all 28 tests passed.
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/hono-route-catalog-conformance.test.ts
test/integration/route-admission-matrix.test.ts --maxWorkers=1 --silent
-u`: all 15 tests passed and regenerated the catalog after rebase.
- Changed TypeScript/Markdown files were formatted with Prettier; commit
hooks passed.

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

Pure resolver red run:

```text
Test Files  1 failed (1)
     Tests  7 failed | 25 passed (32)

AssertionError: team, owner, owner, suspended=false, move: expected { allowed: true } to deeply equal { allowed: false, …(1) }

AssertionError: expected { allowed: true } to deeply equal { Object (allowed, reason) }

- Expected
+ Received

  {
-   "allowed": false,
-   "reason": "not_member",
+   "allowed": true,
  }

AssertionError: expected { canRead: false, …(3) } to deeply equal { canRead: false, …(2) }

- Expected
+ Received

  {
    "canManage": true,
+   "canMove": true,
    "canRead": false,
    "canTrigger": true,
  }
```

Seam red run:

```text
Test Files  3 failed (3)
     Tests  21 failed | 110 passed (131)

TypeError: resolverDecides is not a function

AssertionError: expected { id: 'session-1', …(20) } to not have property "codeServerUrl"

- Expected:
undefined

+ Received:
"https://code.example"

AssertionError: expected 200 to be 403 // Object.is equality

- Expected
+ Received

- 403
+ 200

AssertionError: expected { kind: 'allowed' } to deeply equal { Object (kind, reason) }

- Expected
+ Received

  {
-   "kind": "denied",
-   "reason": "not_member",
+   "kind": "allowed",
  }
```

Post-wiring partial workspace fixture:

```text
FAIL  src/routes/session-runtime-proxy.test.ts > session runtime proxy routes > budget updates > forwards budget updates from the session owner
AssertionError: expected 403 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 403

Test Files  1 failed | 2 passed (3)
     Tests  1 failed | 130 passed (131)
```

Initial integration fixture/expectation failures:

```text
Test Files  2 failed | 1 passed (3)
     Tests  6 failed | 57 passed (63)

AssertionError: expected { …(9) } to match object { httpStatus: 403, …(1) }
(7 matching properties omitted from actual)

- Expected
+ Received

  {
    "httpStatus": 403,
-   "responseCode": "session_action_denied",
+   "responseCode": "not_member",
  }

TypeError: Cannot read properties of undefined (reading 'id')

AssertionError: expected { session: { …(26) }, …(5) } to match object { session: { capabilities: { …(3) } } }
(44 matching properties omitted from actual)

- Expected
+ Received

  {
    "session": {
      "capabilities": {
        "canChangeVisibility": false,
-       "canCollaborate": true,
+       "canCollaborate": false,
        "canRead": true,
      },
    },
  }
```

Full unit run before completing the child-spawn fixture:

```text
FAIL  src/router.spawn-child.test.ts > handleSpawnChild prompt enqueue handling > copies the exact parent provider auth snapshot with immediate inheritance
AssertionError: expected 403 to be 201 // Object.is equality

- Expected
+ Received

- 201
+ 403

Test Files  1 failed | 338 passed (339)
     Tests  26 failed | 5664 passed (5690)
```

Sandbox-readiness fixture:

```text
AssertionError: expected 409 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 409

 Snapshots  1 updated
Test Files  1 failed | 5 passed (6)
     Tests  3 failed | 133 passed (136)
```

</details>

### Baseline Facts and Drift

- Initial clean `main` was `70b8ca4`. The resolver's participant rules
at `packages/shared/src/types/session-access.ts:164-181`, HTTP rollout
checks at `authorization/session-admission.ts:54,87,119`, WebSocket
check at `session/connection-authenticator.ts:559`, membership loading
at `session/components.ts:856`, and snapshot redaction at
`routes/session-runtime-proxy.ts:202-205` matched the described
interaction gap.
- D1 migration `0083` and DO migration `56` remain unchanged. No
migration, table, column, session-creation path, or token-mint change
was needed. The unset enforcement default remains `shadow`.
- During validation, `main` advanced to `3789205` with directory-email
privacy changes and removal of the team Activity route. Rebased onto it
and retained those changes, including `listCollaboratorCandidates`
filtering and its updated web tests. Resolved the shared test-import
conflict without restoring move tests, and regenerated the route catalog
rather than preserving stale indexed captures.
- Consequently the base has 201 routes / 153 paths, rather than the
original 202 / 154. This PR removes exactly one route and one path:
final counts are 200 / 152. Admission snapshot changes remove only the
four scope-route entries.
- The changelog did not have an Unreleased section. Added the
operator-visible behavior change there; dated entries remain historical.

### Deliberately Left Out

- No grants, token narrowing, team secrets, automation/environment scope
routes, or unrelated ownership redesign. Only their existing move
decision/capability fields are removed.
- No migration or retroactive ownership rewrite. Historical
independently scoped-child guard tests use direct SQL fixtures rather
than a deleted move store method.
- No production authentication bypass or permanent UI harness. Browser
verification used the actual action components and application CSS in a
temporary local fixture because no authenticated local backend/browser
session was configured.

## Visual Verification

Viewport screenshots at `http://127.0.0.1:4173` show the real components
in the temporary verification fixture, not an authenticated session
page:

- Desktop, 1512x982: Copy link remains in the menu and Archive remains
available; no move control. Uploaded artifact
`109636aa4532b32fee3cd2db5a27f1b7`.
- Mobile, 390x844: Details, Copy link, and Archive remain; no move
control. Uploaded artifact `84470d42cbff051bf94fd83292909452`.

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

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

## Summary by CodeRabbit

* **Changed**
* Team-owned sessions now require current membership in the owning team
for actions beyond reading, including for Owners and Administrators.
Collaborators can still remove themselves with read access.
* Sessions remain tied to their existing team or workspace; changing
their ownership scope is no longer available.
* Team visibility continues to control read access, and historical
session-move audit records remain readable.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
…up across 1 directory (ColeMurray#2182)

Bumps the npm_and_yarn group with 1 update in the / directory:
[hono](https://github.com/honojs/hono).

Updates `hono` from 4.13.5 to 4.13.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/honojs/hono/releases">hono's
releases</a>.</em></p>
<blockquote>
<h2>v4.13.12</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(build): keep internal types private in bundled d.ts and avoid a
self-referencing JSX.IntrinsicElements in <a
href="https://redirect.github.com/honojs/hono/pull/5485">honojs/hono#5485</a></li>
<li>test(build): type-check the bundled declarations from a consumer
project in <a
href="https://redirect.github.com/honojs/hono/pull/5486">honojs/hono#5486</a></li>
<li>fix(etag): correctly match mixed-case header name in retainedHeader
option in <a
href="https://redirect.github.com/honojs/hono/pull/5475">honojs/hono#5475</a></li>
<li>fix(jsx): add px to numeric gridGap, gridRowGap and gridColumnGap in
<a
href="https://redirect.github.com/honojs/hono/pull/5487">honojs/hono#5487</a></li>
<li>fix(combine): return a Response from a short-circuiting middleware
in some() in <a
href="https://redirect.github.com/honojs/hono/pull/5391">honojs/hono#5391</a></li>
<li>chore(deps): upgrade vite-plus to 1.0.0 in <a
href="https://redirect.github.com/honojs/hono/pull/5464">honojs/hono#5464</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/honojs/hono/compare/v4.13.11...v4.13.12">https://github.com/honojs/hono/compare/v4.13.11...v4.13.12</a></p>
<h2>v4.13.11</h2>
<h2>Security fixes</h2>
<h3><code>serveStatic</code> decodes the request path a second time,
leading to bypass of middleware on static paths</h3>
<p>Affects: <code>hono/serve-static</code> and the adapters built on it
(<code>hono/bun</code>, <code>hono/deno</code>,
<code>hono/cloudflare-workers</code>, <code>@hono/bun</code>,
<code>@hono/deno</code>, <code>@hono/cloudflare-workers</code>). Fixes
<code>serveStatic</code> decoding an already-decoded path, where a
crafted request could be routed as one path and served as another,
skipping middleware mounted on a static prefix. GHSA-5r4p-p66f-jhc7</p>
<p><code>serveStatic</code> now rejects request paths that still contain
<code>%</code> after decoding. To serve files whose names contain a
literal <code>%</code>, set <code>allowPercentInPath: true</code>.</p>
<p>The same fix ships in <code>@hono/node-server</code> v2.1.3.</p>
<h2>v4.13.10</h2>
<h2>Adapters are now separate packages</h2>
<p>The runtime adapters are now published as their own packages:
<code>@hono/bun</code>, <code>@hono/deno</code>,
<code>@hono/cloudflare-workers</code>, <code>@hono/aws-lambda</code>,
<code>@hono/lambda-edge</code>, <code>@hono/netlify</code>,
<code>@hono/vercel</code>, and <code>@hono/service-worker</code>.
<code>@hono/deno</code> is also on JSR.</p>
<p><code>hono/&lt;adapter&gt;</code> still works in v4 but is deprecated
and will be removed in v5. Migrating is an import change:</p>
<pre lang="diff"><code>- import { serveStatic } from 'hono/bun'
+ import { serveStatic } from '@hono/bun'
</code></pre>
<p>hono/cloudflare-pages is deprecated without a replacement package;
Cloudflare recommends Workers with static assets.</p>
<h2>What's Changed</h2>
<ul>
<li>chore: migrate the package manager from bun to pnpm in <a
href="https://redirect.github.com/honojs/hono/pull/5433">honojs/hono#5433</a></li>
<li>chore(package.json): invoke package scripts through pnpm instead of
bun in <a
href="https://redirect.github.com/honojs/hono/pull/5434">honojs/hono#5434</a></li>
<li>chore: replace prettier with oxfmt in <a
href="https://redirect.github.com/honojs/hono/pull/5435">honojs/hono#5435</a></li>
<li>chore(deps): upgrade vitest to 5.0.1 in <a
href="https://redirect.github.com/honojs/hono/pull/5437">honojs/hono#5437</a></li>
<li>chore: let oxfmt sort imports instead of eslint in <a
href="https://redirect.github.com/honojs/hono/pull/5442">honojs/hono#5442</a></li>
<li>chore: replace eslint with oxlint in <a
href="https://redirect.github.com/honojs/hono/pull/5443">honojs/hono#5443</a></li>
<li>chore: introduce Vite+ in <a
href="https://redirect.github.com/honojs/hono/pull/5444">honojs/hono#5444</a></li>
<li>fix(types): allow returning a Blob as a response body in <a
href="https://redirect.github.com/honojs/hono/pull/5446">honojs/hono#5446</a></li>
<li>chore: convert build scripts into plugins in <a
href="https://redirect.github.com/honojs/hono/pull/5448">honojs/hono#5448</a></li>
<li>chore: stop editorconfig-checker from checking Markdown indent size
in <a
href="https://redirect.github.com/honojs/hono/pull/5455">honojs/hono#5455</a></li>
<li>ci: remove empty step left in <code>cr.yml</code> by the pnpm
migration in <a
href="https://redirect.github.com/honojs/hono/pull/5456">honojs/hono#5456</a></li>
<li>chore(deps): upgrade vite-plus to <code>1.0.0-rc.1</code> in <a
href="https://redirect.github.com/honojs/hono/pull/5459">honojs/hono#5459</a></li>
<li>feat(adapters): add <code>@​hono/bun</code> as a workspace package
in <a
href="https://redirect.github.com/honojs/hono/pull/5447">honojs/hono#5447</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/honojs/hono/commit/6abd35b0a5f35f67b6417627d5b0a6c2d266ac04"><code>6abd35b</code></a>
4.13.12</li>
<li><a
href="https://github.com/honojs/hono/commit/95eb860473d26bd303a2a29716e61a77c8c5e5f5"><code>95eb860</code></a>
chore(deps): upgrade vite-plus to 1.0.0 (<a
href="https://redirect.github.com/honojs/hono/issues/5464">#5464</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/afb2068c1c53a530528ac5f237e245793dd03c07"><code>afb2068</code></a>
fix(combine): return a Response from a short-circuiting middleware in
some() ...</li>
<li><a
href="https://github.com/honojs/hono/commit/e5bb2062a3ddd645e3a16d1300bd76d6c9af33a5"><code>e5bb206</code></a>
fix(jsx): add px to numeric gridGap, gridRowGap and gridColumnGap (<a
href="https://redirect.github.com/honojs/hono/issues/5487">#5487</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/c3053ccf14f1c4c70a0c2888eed6121b9f6c8f95"><code>c3053cc</code></a>
fix(etag): correctly match mixed-case header name in retainedHeader
option (#...</li>
<li><a
href="https://github.com/honojs/hono/commit/c437d7569219a21e45fa81797749e8f34fe19dcc"><code>c437d75</code></a>
test(build): type-check the bundled declarations from a consumer project
(<a
href="https://redirect.github.com/honojs/hono/issues/5486">#5486</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/be1f7498fed269544a3c544db0a5a727048462a8"><code>be1f749</code></a>
fix(build): keep internal types private in bundled d.ts and avoid a
self-refe...</li>
<li><a
href="https://github.com/honojs/hono/commit/37ce06904e732d4bc11c9075adf362c76049a594"><code>37ce069</code></a>
4.13.11</li>
<li><a
href="https://github.com/honojs/hono/commit/1e1207cabfcd154146bf6ac9fd6a0b646f7fc484"><code>1e1207c</code></a>
test(serve-static): fix the test (<a
href="https://redirect.github.com/honojs/hono/issues/5479">#5479</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/8b05c774ef1555c70f5ba5e2991b7e77cc3635cc"><code>8b05c77</code></a>
Merge commit from fork</li>
<li>Additional commits viewable in <a
href="https://github.com/honojs/hono/compare/v4.13.5...v4.13.12">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.13.5&new-version=4.13.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/ColeMurray/background-agents/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
**Summary**
Add team-scoped encrypted secrets for team-owned work, with precedence
`global -> team -> environment or repositories`. Workspace-owned
sessions retain their existing secret scopes.

- Add a Zod-validated `TeamSecretsStore` using the existing encryption
key, validation, and per-scope key cap.
- Add human-only team secret GET/PUT/DELETE routes authorized by
`requireTeam("canManageSecrets")` in every enforcement mode.
- Batch secret mutations with `team.secret_set` / `team.secret_deleted`
audit rows containing key names only.
- Resolve sandbox team secrets from current D1 session ownership and
retain the `team` source in the managed-provider filter.
- Include the environment owner's team secrets in environment builds,
never in repository-shared builds.
- Add a capability-gated Secrets tab, reuse the existing editor, and add
encoded browser proxy routes.
- Update audit labels, route policy counts/snapshots, and the operator
changelog.

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

**Checkpoint Report**
1. Commands and results

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run typecheck` | Passed across every TypeScript workspace; rerun
after final editor changes |
| `npm run lint:fix` | Passed; rerun after final changes |
| `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` | 338
files, 5,653 tests passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1` | 130 files passed; 1,669 tests passed, 1 skipped |
| `npm test -w @open-inspect/web -- --maxWorkers=1` | Final rerun: 260
files, 2,476 tests passed |
| `npm test -w @open-inspect/shared -- --maxWorkers=1
src/types/audit-events.test.ts` | 32 tests passed |
| `npm test -w @open-inspect/control-plane -- --maxWorkers=1
src/session/session-target-secrets.test.ts
src/session/user-env-resolver.test.ts src/image-builds/scope.test.ts` |
Red before resolution wiring: 6 failed, 60 passed. Green afterward: 66
passed |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1 test/integration/team-secrets.test.ts
test/integration/team-secrets-routes.test.ts
test/integration/team-secrets-store.test.ts` | Initial run: 9 failed, 38
passed. Six resolution failures before wiring, three invalid bot-actor
fixtures corrected |
| `npm run test:integration -w @open-inspect/control-plane --
--maxWorkers=1 test/integration/team-secrets.test.ts
test/integration/team-secrets-routes.test.ts
test/integration/team-secrets-store.test.ts
test/integration/hono-route-catalog-conformance.test.ts
test/integration/route-admission-matrix.test.ts` | 61 tests passed |
| `npm test -w @open-inspect/web -- --maxWorkers=1
src/components/teams/team-secrets.test.tsx
src/components/teams/teams-pages.test.tsx
src/components/settings/audit-log-settings.test.tsx
"src/app/api/teams/[id]/secrets/route.test.ts"
"src/app/api/teams/[id]/secrets/[key]/route.test.ts"` | Initial targeted
frontend run: 78 tests passed |
| `npm test -w @open-inspect/web -- --maxWorkers=1
src/components/teams/team-secrets.test.tsx` | Final targeted editor run:
13 tests passed, including a scope-switch mutation regression test |
| `git ls-files --modified --others --exclude-standard -z \| xargs -0
npx prettier --write --ignore-unknown` | Formatted intended changes |
| `git diff --check` and `git diff --check origin/main...HEAD` | Passed
|

Verbatim red unit assertion excerpts:

```text
FAIL  src/image-builds/scope.test.ts > loadScopeBuildSecrets > loads the environment owner's team before environment secrets
AssertionError: expected "vi.fn()" to be called with arguments: [ 'team_build' ]
Number of calls: 0

FAIL  src/session/session-target-secrets.test.ts > buildSessionTargetSecretSources > merges team after global and before target secrets for target null
AssertionError: expected [ 'global', 'acme/web' ] to deeply equal [ 'global', 'team', 'acme/web' ]

FAIL  src/session/session-target-secrets.test.ts > buildSessionTargetSecretSources > merges team after global and before target secrets for target env_team
AssertionError: expected [ 'global', 'environment' ] to deeply equal [ 'global', 'team', 'environment' ]

FAIL  src/session/session-target-secrets.test.ts > buildSessionTargetSecretSources > includes team secrets in an ad-hoc session without repositories
AssertionError: expected { SHARED: 'global' } to deeply equal { SHARED: 'team' }

FAIL  src/session/user-env-resolver.test.ts > UserEnvResolver > session-target secret fold > includes team broker secrets from the current public D1 session ownership
AssertionError: expected { SHARED: 'global' } to deeply equal { SHARED: 'team', …(1) }

FAIL  src/session/user-env-resolver.test.ts > UserEnvResolver > session-target secret fold > fails closed when the authoritative session index row is missing
AssertionError: promise resolved "undefined" instead of rejecting

Test Files  3 failed (3)
     Tests  6 failed | 60 passed (66)
```

Verbatim initial integration assertion excerpts:

```text
FAIL  test/integration/team-secrets-routes.test.ts > team secrets routes > denies members, nonmembers, bots and suspended leads in off mode
FAIL  test/integration/team-secrets-routes.test.ts > team secrets routes > denies members, nonmembers, bots and suspended leads in shadow mode
FAIL  test/integration/team-secrets-routes.test.ts > team secrets routes > denies members, nonmembers, bots and suspended leads in on mode
AssertionError: github-bot/slack:U-SECRET-ACTOR/GET: expected 401 to be 403 // Object.is equality

FAIL  test/integration/team-secrets.test.ts > team secret resolution > gives environment precedence over team and global and retains team broker credentials
AssertionError: expected { SHARED: 'environment', …(2) } to deeply equal { SHARED: 'environment', …(4) }

FAIL  test/integration/team-secrets.test.ts > team secret resolution > gives team precedence over global without letting member repositories change the broker
AssertionError: expected { SHARED: 'global', …(2) } to deeply equal { SHARED: 'team-a', …(4) }

FAIL  test/integration/team-secrets.test.ts > team secret resolution > keeps primary repository precedence over team secrets
AssertionError: expected 'global' to be 'team-a' // Object.is equality

FAIL  test/integration/team-secrets.test.ts > team secret resolution > never injects another team's secrets or any team secrets into workspace sessions
AssertionError: expected { SHARED: 'global', …(2) } to deeply equal { SHARED: 'team-b', …(3) }

FAIL  test/integration/team-secrets.test.ts > team secret resolution > uses current D1 ownership after a session moves teams
AssertionError: expected undefined to be 'b' // Object.is equality

FAIL  test/integration/team-secrets.test.ts > team secret resolution > adds the environment owner's team to builds but never to repository-shared builds
AssertionError: expected { SHARED: 'environment', …(2) } to deeply equal { SHARED: 'environment', …(4) }

Test Files  2 failed | 1 passed (3)
     Tests  9 failed | 38 passed (47)
```

The bot fixture used a Slack actor for GitHub and Linear credentials.
Fixtures now use valid service-specific actors and still assert exactly
403; production authentication was not changed.

The passing full integration suite emitted NDJSON decoding warnings and
diagnostics from deliberate eviction/invalid-D1 fixtures. The passing
web suite emitted a `TimeoutNaNWarning` and jsdom navigation warning.

2. Verified facts and drift

Branched from `main` at `19e7993`, still matching `origin/main` before
opening. Existing D1 migration `0083` supplies `team_secrets` and
`environments.owner_team_id`; no migration was added. The main session
fold, broker filter, separate build fold, encryption helpers, key cap,
and human team capability admission match the cited behavior. Catalog
baseline was 202 routes / 154 paths; this adds three routes / two paths,
resulting in 205 / 156.

The DO `SessionRow` has no team ownership field
(`session/types.ts:36-65`). Ownership is authoritative in D1
(`db/session-index.ts:80,448-452`), so the resolver reads the existing
index by public session name on each resolution instead of adding DO
state or a migration. Tests cover ownership changes and a missing index
row.

3. Deliberately excluded / limitations

- No new migration, schema field, ownership mutation, repository
grant/token change, or edits to the parallel team/environment/planner
work.
- `OAuthSecretScope` remains `environment | repo | global`; no team
OAuth persistence/write-back was added. Team-only legacy OAuth refresh
tokens are not end-to-end supported: the requested managed-source fold
retains the team layer, but existing OpenAI/xAI brokers still read only
target/global scopes (`session/openai-token-refresh-service.ts:43-46`,
`session/xai-token-refresh-service.ts:78-85`). Team API keys and
ordinary secrets use the new precedence normally. The broker paths were
deliberately left unchanged rather than expanding the
credential/write-back design.
- No new snapshot or prebuilt-image invalidation guarantees. Current
resolution is not revocation of credentials already baked into existing
artifacts. Per-scope capacity retains the existing stores'
read-before-write concurrency behavior.
- Visual verification used the real Next team page with local synthetic
auth/API fixtures, not a production login or remote persistence.

**Visual Verification**
Opened `http://127.0.0.1:3000/teams/platform` as a team lead, verified
tab visibility, list metadata, create/update/delete, clearing saved
values, and no horizontal overflow at mobile width. Uploaded viewport
captures:

- Desktop 1440x1000: artifact `55a3727e9b76c72f089518af573c4e60`.
- Mobile 390x844, sidebar closed: artifact
`4891293b8afb43836a4f8de6bcacfeb4`.

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

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

* **New Features**
* Team members with secret-management access can manage encrypted team
secrets from the team’s Secrets tab, including adding, updating, and
deleting secrets.
* Team-owned sessions and environment image builds can use team secrets;
repository-shared image builds do not. Team secrets override global
secrets, while environment or repository secrets take precedence where
applicable.
* Audit logs identify team-secret changes without exposing secret
values, and secret lists show key metadata rather than stored values.
* Team-secret changes supersede affected environment images and trigger
best-effort rebuild scheduling for eligible team environments.

* **Bug Fixes**
* Environment image builds now fail rather than proceed when team
secrets cannot be read or decrypted.
<!-- 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>
…rray#2186)

Follow-ups from the review of ColeMurray#2184. That PR was merged before these
could be pushed to it.

## Behavior changes

- **Collaborators on team-owned sessions must be team members.** `PUT
/sessions/:id/collaborators/:userId` now returns `409 not_team_member`
when the session is team-owned and the target user isn't in the owning
team. Previously the grant returned `updated` but couldn't be used,
because the `not_member` check from ColeMurray#2184 denies every non-read action
to non-members. Re-adding an existing non-member collaborator also
returns the 409 now, instead of `unchanged`. Sessions without an owning
team behave as before.
- **The collaborator picker only lists the owning team.** For team-owned
sessions, `GET /sessions/:id/collaborator-candidates` returns only
members of the owning team. `UserStore.listCollaboratorCandidates` takes
an optional `teamId` for this.
- **Batch archive reports `not_member`.** Sessions skipped because of
team membership are reported as `not_member` instead of
`missing_permission`, so admins who have `sessions.lifecycle` see the
real cause. The shared `sessionBatchArchiveResponseSchema` gains this
value. The web route only passes the response through, so no deployed
client parses it.

## No behavior change

- `effectiveSessionCapabilities` reuses the results
`sessionCapabilities` already computed, instead of calling
`checkSessionAccess` again for each action. This runs on every
session-list and inbox row.
- `updateSessionScope` no longer refetches `/api/me/teams`. That refetch
was only needed by the removed move-and-join-team flow.
- Removed the unused `TeamStore.isActive` and made
`TeamMembershipStore.bindAddIfJoinable` private.

## Testing

- New integration tests cover rejecting non-member collaborators on
team-owned sessions, filtering the picker, and the `not_member`
batch-archive reason in `off`, `shadow` and `on` modes.
- `npm run typecheck`, ESLint and Prettier pass.
- Control-plane unit tests (5690), the full control-plane integration
suite (1648 passed, 1 skipped) and the web `session-scope` tests (88)
pass.

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

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

* **New Features**
* Collaborator suggestions for team-owned sessions are limited to active
members of the owning team, and adding a non-member is rejected.
* Batch archive results identify sessions skipped because the requester
is not a team member.
* **Bug Fixes**
* Collaborators on team-owned private sessions can access them only
while they remain members of the owning team. Leaving or being removed
from the team ends access, while the collaborator record remains.
* Batch archive results distinguish missing sessions, team-membership
restrictions, and permission issues.
* Updating session scope no longer triggers an unnecessary
membership-data refresh.
<!-- 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#2187)

Follow-up to ColeMurray#2186. Route handlers should not contain raw SQL.

## Changes

- Added `UserStore.getCollaboratorEligibility(userId, teamId)`. It runs
the existing user, role assignment and team membership lookup, and
returns one of `"eligible" | "not_found" | "inactive" |
"not_team_member"`. It sits next to `listCollaboratorCandidates`, which
applies the same rules (active user, optionally restricted to a team).
- `changeCollaborator` in `routes/session-scope.ts` now calls the store
and maps each outcome to the same HTTP responses as before (`404`, `409
user_inactive`, `409 not_team_member`). The route no longer contains raw
SQL or the zod row schema.

No behavior change: the SQL, the eligibility rules and the responses are
unchanged.

## Testing

- New `UserStore` integration test covering every outcome: missing,
suspended, no role assignment, eligible without a team, not a team
member, and team member.
- The existing route-level tests for `session-scope-routes`,
`session-access-routes` and `collaborator-candidates` pass unchanged.
- Control-plane typecheck and ESLint pass. Control-plane unit tests pass
(5718).

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Collaborator changes now consistently check whether the selected
person is eligible, active, and—when applicable—a member of the
specified team.
* Existing error responses remain in place for missing users, inactive
users, and people who are not team members.
* **Tests**
* Added coverage for eligibility outcomes, including active users with
and without team membership.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
…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 -->
…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>
ColeMurray and others added 28 commits October 24, 2026 20:56
…2515)

## Why

Reviewing agent proposals in **Settings › Memories / Shared memories**
takes one click per record, which is slow when many proposals are
waiting. This adds selecting several records and applying one action to
all of them. It also covers bulk "delete": memories have no hard delete
by design (`docs/MEMORY.md`, which keeps historical session manifests
valid), so bulk removal means bulk **archive**, or bulk **reject** for
proposals.

## What changed

**Shared contract** (`packages/shared/src/types/memories.ts`)
- `bulkMemoryActionSchema`: `{ action, memories: [{ id, revisionId }],
archiveNote? }`. It holds 1 to `MEMORY_BULK_ACTION_MAX_RECORDS` records
(one max page, 100), and IDs must be unique. `archiveNote` is accepted
only for actions that archive (`reject`, `archive`), matching the
single-record body schemas.
- `bulkMemoryActionResponseSchema`: one `{ id, ok: true, memory }` or `{
id, ok: false, error }` per record, in request order.

**Control plane**
- New `POST /memories/bulk` (same `activeSelf` admission and `private,
no-store` as the other memory routes).
  - Loads all targets in one query (`MemoryRecordStore.getMany`).
- Authorizes them with the new
`MemoryManagementPolicy.authorizeRecords`. This loads environment rows
and repository grant owners once for the whole request; the
shared-catalog path now uses the same preload.
- Applies the **existing** `store.transition` to each record in order.
Revision checks, lifecycle guards (replacement family, pending-proposal
quota, predecessor) and the per-record audit event are unchanged.
- A record that is missing, concealed (another user's personal record),
forbidden or stale fails on its own and the rest still apply. Unexpected
errors are still thrown.

**Web**
- BFF route `/api/memories/bulk` and an `applyBulkMemoryAction` client
helper.
- Records with at least one granted action get a checkbox. A toolbar
offers "Select all on this page" (with an indeterminate state), a
selected count, and the actions every selected record allows.
  - Approve and Restore run right away.
- Reject and Archive open a confirmation panel with one optional shared
archive note. It uses the collection's existing single-panel model, so
it replaces any open card panel.
- After a bulk action the page refreshes and shows a summary ("Approved
38 of 40 memories."). Any failure is listed by title with the server's
reason.
- The selection resets when the status tab, page or search changes, and
records that leave the page drop out of it.

**Docs**: `docs/MEMORY.md` (API table and management behavior).

## Testing

- `memories-bulk-actions.test.ts` (new integration test, real D1):
- Mixed outcomes in one request: managed repo, managed environment and
own personal records succeed. These fail: a readable-only repo, an
ungranted repo, another user's personal record, a stale revision, a
wrong status and a missing ID. Results come back in request order with
the right messages.
  - Audit events are written only for the records that applied.
- Admission is loaded once (`listOwnersForRepositories` and `getByIds`
each called once, and no per-repo lookups).
  - Archive with a shared note, then restore.
  - Malformed requests get a 400 before any `transition` runs.
- Unit tests:
- `MemoryManagementPolicy.authorizeRecords` (decision order, preload
before evaluation, no name resolution).
  - The shared schema bounds.
  - `applyBulkMemoryAction` request shape.
  - `bulkActionSummary`.
- UI flows: selection, select-all, bulk approve with a partial failure,
bulk archive confirmation with a note, selection reset on tab change.
- Route catalog and admission snapshots updated (`POST /memories/bulk
owner=400`, `anonymous=401`), and route counts bumped.
- Ran: shared memory tests; web memory settings, hook, lib and
settings-page tests; the full control-plane unit suite; the memory and
route-conformance integration suites. Typecheck (shared, control-plane,
web) and ESLint pass.

Not done: I didn't check the UI in a running browser. Layout is covered
only by the component tests.

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

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

## Summary by CodeRabbit

* **New Features**
* Select eligible memories on the current page to approve, reject,
archive, or restore in bulk.
  * Optionally add a shared note when archiving selected memories.
* **Improvements**
* Review per-record results, including errors when an action fails;
successful changes aren’t blocked by other failures.
* Actions are checked against each memory’s reviewed version and your
permissions.
  * Larger selections are processed in batches.
* Selection clears when you change the view, such as switching status
tabs, searching, or paging.
<!-- 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>
…ay#2516)

Follow-up to ColeMurray#2509, which merged 29 seconds before its
[review](ColeMurray#2509 (review))
was posted. The merged code is identical to the reviewed head,
`4c8b388`, so every finding applies to `main`. This PR acts on the
validated findings in two commits: a refactor with no behavior change,
then a separate fix that can be reviewed or dropped on its own.

### 1. `refactor: return SandboxAlarmResult from fatal-report
termination` (no behavior change)

**One result type for both recovery paths.** `FatalTerminationOutcome`
mapped one-to-one onto the documented `SandboxAlarmResult`:

| removed | now |
|---|---|
| `not_terminated` | `"no_action"` |
| `stop_failed`, or a held retained boot | `"sandbox_failed"` ("held for
recovery, so pending prompts wait") |
| `terminated` | `"sandbox_terminated"` |
| `retries_exhausted` | `{ kind: "fatal_report_unrecoverable"; reason }`
|

`terminateFailedSandbox` now returns `SandboxAlarmResult`, which gains
the `fatal_report_unrecoverable` variant. The exported enum is deleted;
nothing in production code ever checked `stop_failed`.

**One applier.** `handleFatalSandboxFailure` had re-implemented the
alarm handler's "re-drive on termination, fail the boot prompt on an
abandoned boot" block. Both now call `applySandboxRecovery` in the new
`session/sandbox-recovery.ts`, and the alarm handler's comments moved
into its doc.

**No `countedOutcome` sentinel.** The fatal result was decided across
five returns plus a mutable fallback. That fallback existed only because
the catch-all outlived its original reason when the provider stop moved
to `stopProviderSandboxSafely`, which never throws. Now:
- The work after the failure is counted (publish, detach, stop) lives in
`tearDownFailedSandbox`, which logs and returns `false` instead of
throwing.
- The result is decided at one return site, from `held`, `exhausted` and
`tornDown`.

**Count and check the breaker in one step.** `recordSpawnFailure` now
returns whether the breaker is open. This replaces three copies of
"record the failure, then call `isCircuitBreakerOpen`", and removes
`isCircuitBreakerOpen` from `WatchdogEffectsDependencies`. The answer is
the same: right after counting at `now`, the breaker is open exactly
when `failureCount >= threshold`.

Test changes in this commit are mechanical outcome renames. The two
held-retained-boot assertions in `manager-shutdown.test.ts` change from
`no_action` to `sandbox_failed`; the queue treats those two results
identically.

### 2. `fix: settle the boot prompt when a fatal report's teardown
fails` (behavior change)

**Before:** after a fatal report on a booting sandbox, if the provider
stop failed (or cleanup threw) while retries remained, the row was
`failed` and nothing re-drove the queue.
- `handleAlarm()` sees a terminal row.
- `redriveIdleQueue()` only wakes `ready` runtimes.

So a Slack, Linear or GitHub prompt stayed `pending`, and its caller
never got `notifyComplete`, until someone sent another message. That's
the same bug ColeMurray#2509 fixed for the breaker-open case. The
connecting-timeout watchdog already fails the prompt when its stop fails
("instead of waiting with nothing scheduled to retry it").

**After:** an unfinished teardown resolves `fatal_report_unrecoverable`,
so the boot prompt fails with the fatal reason. A held retained boot
still resolves `sandbox_failed` and fails nothing.

**Regression test:** `sandbox-fatal-report-recovery.test.ts` gains
"fails a pending prompt when the failed sandbox cannot be stopped for a
replacement". It stubs `ModalSandboxProvider.stopSandbox` to fail.
Without the fix it fails (`{ status: "pending" }`); with it, it passes.

### Validation

```sh
npm run build -w @open-inspect/shared   # ColeMurray#2510 changed shared types
npm run typecheck -w @open-inspect/control-plane   # clean
npx vitest run src/sandbox/lifecycle/ src/session/message-queue.test.ts \
  src/session/http/handlers/sandbox-error.handler.test.ts src/session/sandbox-shutdown.test.ts --maxWorkers=1
# 535 passed
npx vitest run --config vitest.integration.config.ts test/integration/sandbox-fatal-report-recovery.test.ts \
  test/integration/session-lifecycle-alarm-recovery.test.ts test/integration/sandbox-early-connect.test.ts \
  test/integration/sandbox-state-retention.test.ts test/integration/sandbox-shutdown.test.ts --maxWorkers=1
# 74 passed (the 73 from ColeMurray#2509 plus the new regression test)
```

ESLint and Prettier are clean on the changed files.

### Not changed

- **Boot-prompt guard.** `handleFatalSandboxFailure` still skips the
boot prompt when a prompt is processing or awaiting stop confirmation,
while the alarm handler uses a bare `getNextPendingMessage()`. Because
`onPromptDispatched()` resets the breaker, that guard only matters if
the reset write fails or a `processing` row is orphaned. Unifying the
two definitions changes which prompt fails in those cases (a CodeRabbit
review on ColeMurray#2509 asked for the guard), so that decision is left for a
separate change.
- **ColeMurray#2509 description.** It still says `SandboxLifecycle` exposes
`isCircuitBreakerOpen()` (reverted in 4fbed3a), and that prompts are
re-driven "while retries remain" (only when the stop succeeds). The
squash commit `593a6a2` carries that text, so this PR body is the
correction on record.
- **Possible overlap with ColeMurray#2462:** it also edits `message-queue.ts`.
This PR only touches `handleFatalSandboxFailure` there.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved recovery from fatal sandbox errors: successful termination
resumes execution, while failed termination reports an unrecoverable
error and fails the pending prompt with the fatal error.
* Avoided duplicate recovery actions when a sandbox is already stopped,
termination is underway, or shutdown/checkpoint handling is in progress.
* Improved retry handling for sandbox startup failures so exhausted
retries are reported consistently.

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

---------

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

The scheduler still combines invocation orchestration with session setup
and first-prompt delivery. Extract those workflows into
constructor-injected services, reducing `scheduler.ts` from 2,259 to
1,764 lines while preserving admission and launch ordering.

## Changes

- `AutomationSessionService` resolves session inputs, refines the
deadline from the session claim time, rechecks authority and team
visibility, selects skills/memory, and initializes the session.
- `AutomationRunLaunchService` claims the session, queues its first
prompt, releases held follow-ups, and classifies launch failures.
Initialization failures remain eligible for retries; failed or ambiguous
prompt delivery does not start a second session.
- Share team-target and provider-snapshot admission functions between
first launches and retries. Centralize launch contracts and
deadline/backoff policy, with construction in `createScheduler`.
- Move first-prompt assembly and its stable request ID into the existing
prompt module. Preserve compatibility exports and existing SQL/store
operations.
- Add 17 direct boundary tests while retaining scheduler and real-D1
integration coverage.

This is PR 2 of the scheduler refactor, following
ColeMurray#2520. Invocation
admission, failure accounting, and recovery orchestration remain for PR
3; event handling follows in PR 4.

## Validation

- 244 focused unit tests passed across nine files.
- 200 integration tests passed across 11 affected suites. Workerd
emitted the same teardown warning observed during PR 1; the run exited
successfully.
- All control-plane typechecks, targeted ESLint, formatting, and
Worker/Node builds passed.
- Compared retained scheduler methods and extracted launch/session
bodies against the merged baseline: changes are dependency access and
argument packaging, with workflow order preserved.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Automation runs now initialize sessions using the applicable target,
settings, shared skills, and provider authentication.
* Runs receive execution deadlines based on their sandbox settings, and
their initial instructions are queued with a stable request ID.
* **Reliability**
* Launch failures are recorded with retry handling for eligible errors.
Authorization and provider configuration are checked before launch, and
authorization failures are marked accordingly.
* Follow-up work can be released after a run starts; errors during
release no longer fail the launch.

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

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

The scheduler still combines source orchestration with invocation
admission, retry recovery, and failure accounting. Extract those
workflows into injected services, reducing `scheduler.ts` from 1,765 to
915 lines while preserving existing claim and write ordering.

## Changes

- `AutomationInvocationService` prepares and admits firings, accepts
webhook receipts after insertion, resolves lazy launch inputs, launches
children, and accounts for immediate failures. Its separate `retryDue`
workflow prepares and conditionally claims retries, retaining
post-insert attribution for first launches and pre-claim
attribution/provider resolution for retries.
- `AutomationRecoveryService` repairs orphaned/expired runs and missed
accounting, with no launch-admission dependencies. The scheduler retains
recovery → held prompts → retries → due firings; retry results still
count claimed attempts, including failed attempts.
- `AutomationFailureService` owns the invocation failure claim,
consecutive-failure counter, pause threshold, and success reset. Errors
still propagate to each caller's existing containment policy.
- Wire collaborators through `createScheduler`; share launch budgets,
bounded concurrency, owner-principal resolution, and SCM attribution
without introducing new persistence operations.
- Add 18 focused tests for admission/retry boundaries, failure
accounting, and tick sequencing. Keep completion ordering and
source-specific results in `Scheduler`.

This is PR 3 of the scheduler refactor, following
ColeMurray#2521. Event
handling and final cleanup remain for PR 4. Existing
accounting/publication crash windows are unchanged.

## Validation

- 193 targeted unit tests passed across invocation-services, scheduler,
launch-services, and factory.
- 80 real-D1 integration tests passed across automation-launch-retries,
automation-invocations, and scheduler.
- All control-plane typechecks, targeted ESLint, formatting, and
Worker/Node builds passed.
- Compared extracted and retained method bodies against the merged
baseline: workflow bodies match after dependency/delegation
substitutions.

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

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

## Summary by CodeRabbit

* **Improvements**
* Automation runs now handle duplicate triggers and overlapping
executions more consistently.
* Failed runs can be retried or recovered automatically, including runs
that stall during startup or exceed their execution deadline.
* Repeated failures can pause an automation, while successful runs reset
its failure streak.
* Automation launches and retries are limited per scheduler cycle, with
concurrent launches capped to help manage activity.
  * GitHub attribution is included when it can be resolved reliably.
<!-- 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>
…urray#2524)

## Summary
- Skip the final whole-repo typecheck in `.openinspect/setup.sh` only
when `OPENINSPECT_BOOT_MODE=fresh`, preserving dependency installation,
the shared build, hook checks, and Python setup.
- Keep typechecking for image builds and local runs, including safe
handling of an unset boot-mode variable and the existing warning-only
failure behavior.
- Enable inherited TypeScript incremental caching for all four
control-plane projects.
- Clarify reusable TypeScript caches and boot-mode guards in the
prebuilt-image documentation.

## Validation
- `bash -n .openinspect/setup.sh` passed.
- Smoke-tested the actual setup script with stubbed provisioning
commands for fresh, build, unset, and empty boot modes, plus typecheck
failures. Confirmed the preceding setup steps still run and setup
completes.
- `npm run typecheck -w @open-inspect/control-plane` passed twice:
**35.84 s cold**, **9.74 s warm**, about **3.7x faster** on the second
run.
- Confirmed all four expected `.tsbuildinfo` files exist and are ignored
by Git.
- Whole-repo `npm run typecheck` passed.
- Targeted Prettier checks and `git diff --check` passed.

Resolves COL-284.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Performance**
* Fresh session setup now skips type checking, helping sessions start
faster. Type checks continue during other setup modes.
  * TypeScript compilation can reuse cached results.

* **Documentation**
* Updated image prebuild guidance to explain reusable tool caches and
when to warm them.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Scheduler still owned event matching, Slack thread continuation, and
webhook receipt leasing alongside tick and completion orchestration.
Extract `AutomationEventHandler`, `SlackEventHandler`, and
`WebhookEventHandler`, reducing `scheduler.ts` from 915 to 351 lines.

- Construct each source handler with its own collaborators in the
factory. The dispatcher receives only their `handle(event)` interfaces;
continuation-policy builders are private methods of the source handlers.
- Resolve SCM provider configuration at construction. Source handlers
use `SessionContinuityService`, shared `AutomationAttributionService`,
stores, and the existing clock/crypto APIs instead of raw function
dependencies. Actor authorization and attribution remain lazy and run at
the original admission points.
- Keep Slack continuation before trigger conditions and its lazy
actor/context/settings reads local to each delivery, including when a
handler instance is reused.
- Keep webhook receipt reconciliation before conditions, continuation
after conditions, and initial receipt acceptance inside invocation
admission. Uncertain delivery retains its lease.
- Bind stores, clocks, IDs, GitHub admission, and continuity adapters in
the factory. Retry admission remains with `AutomationInvocationService`.
- Share event result accounting and prompt composition, remove temporary
scheduler re-exports, and cover Linear/Sentry candidate filtering and
condition-gated admission directly.

Validation: 191 focused unit tests and 52 real-D1 Slack/webhook/retry
integration tests passed. All four control-plane TypeScript
configurations, changed-file ESLint/Prettier checks, Worker and Node
builds, and `git diff --check` passed.

The integration runner emitted a teardown RPC warning after passing
tests; it exited successfully.

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

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

## Summary by CodeRabbit

* **New Features**
* Added event-driven automation handling for GitHub, Slack, webhooks,
Linear, and Sentry.
* Slack and webhook events can continue existing sessions, and webhook
deliveries are checked to prevent duplicate processing.
* Event context is separated from automation instructions, with
safeguards against context overriding trusted instructions.
* **Bug Fixes**
* Invalid trigger conditions and skipped or refused event deliveries are
handled and reported more consistently.
<!-- 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>
Scheduler services still accepted standalone clock, ID-generation, and
held-prompt callbacks after the event handlers moved to explicit
collaborators. Apply the same clock and crypto dependencies to
scheduling, invocation admission, recovery, run launch, and Slack
publication.

Expose held-prompt release and sweeping through
`SessionContinuityService`, allowing the factory to inject one
continuity service throughout. Preserve timestamp/ID generation points,
recovery → held prompts → retries → due firings ordering, and
first-prompt marking before follow-up release.

Validation: 214 targeted unit tests and 96 real-D1 integration tests
passed. All four control-plane TypeScript configurations, changed-file
ESLint/Prettier checks, Worker and Node builds, and `git diff --check`
passed.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
* Standardized how scheduler operations handle timestamps, identifier
generation, and session continuity. Scheduling and recovery behavior
remains unchanged.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- Add Boat base pruning using the existing API transport and
`/saved-snapshots` endpoints. Require explicit deployed/replaced
references and validate both before deleting anything.
- Derive the rollback reference from confirmed Cloudflare Worker
deployment history, not artifact creation times. Verify the active
deployment and target, skip same-base redeploys, and fail closed on
ambiguous history.
- Restrict cleanup to exact `<prefix>-<16 lowercase hex>` names and
reserve `oi-img`/`oi-img-*` base prefixes so prebuilt-image names cannot
overlap. Manual builds, saving snapshots, and other prefixes remain
untouched.
- Run cleanup after the Worker deployment, with snapshot name, API URL
and organization in the non-secret triggers. No destroy-time cleanup is
added.
- Cover reverts followed by another deploy, undeployed ready artifacts,
retries, namespace collisions, history safety checks and target changes;
document manual previews and rollback limits.

## Verification
- Sandbox-images pytest suite: 161 passed.
- Ruff lint and formatting checks passed for sandbox-images and Boat
tooling.
- Terraform 1.14.8 validation and targeted Boat backend tests: 9 passed.
- Recursive Terraform formatting, Prettier and diff checks passed.

A read-only dry run against the real wallet remains required before
merge; this sandbox has no Boat credentials or deployed snapshot
configuration. No live API calls or snapshot deletions were performed.
Deployments, direct Worker rollbacks and pruning must remain serialized.

Fixes
[COL-285](https://linear.app/colemurray/issue/COL-285/delete-superseded-boat-base-snapshots-after-each-base-deploy-keeping).

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

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

## Summary by CodeRabbit

* **New Features**
* Automatically prunes eligible superseded Boat base snapshots after the
Worker switches to a verified snapshot, retaining the deployed base and
its confirmed predecessor for rollback.
* A dry run previews cleanup without deleting snapshots. Cleanup is
skipped when deployment history cannot confirm a predecessor; manual
snapshots, prebuilt images, and snapshots outside the configured naming
pattern are excluded.
* **Documentation**
* Clarified snapshot naming, wallet-based allowance usage, cleanup
behavior, and rollback considerations. Use a unique prefix for each
deployment sharing a Boat wallet to avoid treating their snapshots as
belonging to the same deployment.
<!-- 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>
Slack and webhook event handlers assembled continuation behavior as
callback objects that captured their dependencies. Replace them with
`SlackContinuationPolicy` and `WebhookContinuationPolicy`, implementing
the existing policy contract with constructor-injected collaborators and
ordinary methods.

Two small source-specific factories create delivery-scoped policies.
Slack shares a lazy actor lookup across candidates within one delivery,
including null results and failures, while prompt construction receives
each candidate's automation metadata. Webhook policies retain the
delivery claim and stable prompt ID; receipt acceptance/release,
authorization, hashing timing, and held-follow-up ordering remain
unchanged. The handlers no longer receive policy-only identity and
attribution dependencies.

Validation:
- 184 targeted unit tests passed across event handlers, scheduler, and
factory, including 10 new policy cases.
- 35 real-D1 integration tests passed across Slack events, team
steering, and webhook sessions, covering immediate and held follow-ups.
The runner logged an RPC teardown error but reported all tests passed
and exited 0.
- All four control-plane TypeScript configurations passed.
- Changed-file ESLint and Prettier checks, Worker/Node builds, and `git
diff --check` passed.

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

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

## Summary by CodeRabbit

* **Improvements**
* Slack and webhook-triggered automations now provide more consistent
session continuations, including actor attribution and delivery
tracking.
* Slack identity lookups are reused during event handling. Webhook
requests retain consistent identifiers when none are provided.
* Slack prompts include thread context, and webhook continuations use
the automation’s canonical owner when available.
* **Tests**
* Added coverage for continuation behavior, identity resolution,
attribution, request identifiers, and webhook delivery handling.
<!-- 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>
GitHub automation admission duplicated the event counters, invocation ID
collection, and logging already shared by the other event handlers.
Reuse `recordEventLaunch` and `logEventResult` after GitHub-specific
grant and retry decisions settle, so event outcome semantics have one
owner.

The bounded retry loop, fresh authority reads, and grant-denied history
remain in place. Intermediate retry outcomes are not counted. Added
coverage for deduplication, recorded/unrecorded overlap, blocked
overlap, failed launches, and final logging.

Validation: 41 focused unit tests and 22 real-D1 GitHub routing
integration tests passed. All four control-plane TypeScript
configurations, changed-file ESLint/Prettier, and `git diff --check`
passed.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved consistency when processing duplicate or overlapping GitHub
events, including blocked and unsuccessful launches.
* Avoided a spurious warning when coverage returns before a
post-admission denial is recorded, and improved the accuracy of event
outcome reporting.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
SessionContinuityService previously forwarded every operation into a
separate free-function implementation, which also constructed
authorization collaborators and read the global clock and ID generator.
Move that implementation directly into the existing service, with
internal methods private and the public continuation contracts retained
separately.

The composition root now injects authorization, clock, and crypto
collaborators. The store dependency is narrowed to its nine used
operations. Authorization remains lazy and is read afresh when resolving
an actor or releasing held prompts; Slack's delivery-scoped actor cache
and per-session admission checks retain their existing ownership. Remove
the temporary firstPromptRequestId re-export and import it from
session-prompt in its remaining test caller.

The control flow, receipt handling, historical lookups, and held-prompt
ordering are preserved. New focused tests exercise interface-only
dependencies, revoked/unavailable authorization at held-prompt release,
and deterministic prompt IDs/timestamps. This PR is independent of ColeMurray#2529
and targets main directly.

Validation:
- 211 targeted unit tests passed across continuity, event handlers,
scheduler, launch services, and factory, including eight new dependency
tests.
- 35 real-D1 Slack events, team steering, and webhook session
integration tests passed. The runner logged an RPC
EnvironmentTeardownError but reported all tests passed and exited 0.
- All four control-plane TypeScript configurations passed.
- Changed-file ESLint/Prettier, Worker/Node builds, and git diff --check
passed.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Session continuation now checks current authorization before accepting
collaboration. Suspended actors and actors without collaboration access
are denied.
* Held follow-ups are removed when authorization is revoked and retained
if authorization cannot be verified.
* **Improvements**
* Recent sessions can continue for up to seven days. Held follow-ups are
retried after two minutes, with each retry sweep limited to 50 runs.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Completion coordination tests currently construct the entire scheduler
factory and depend on unrelated module mocks. Add a small, typed
constructor-injected harness for `Scheduler.runComplete` so failures
identify the scheduler's own contract: guarded writes, first-prompt
ordering, accounting, publication, duplicates, timeout correction, and
error propagation.

Move the repository-free Slack label assertion to the Slack service
tests and preserve sibling-accounting cases in the failure service
tests. Retain composed factory coverage for duplicate publication after
preparation failure and late timeout correction, alongside the existing
D1 completion/accounting tests. Production code is unchanged.

Validation:
- 199 targeted unit tests passed across scheduler completion, scheduler,
invocation services, and Slack service; the nine new completion cases
also passed after the typed fixture correction.
- Eight real-D1 tests passed for run completion and sibling finalization
(`scheduler.test.ts`, filtered).
- All four control-plane TypeScript configurations passed.
- Changed-file ESLint, Prettier, and `git diff --check` passed.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Tests**
* Expanded verification of run completion outcomes, including successful
and failed runs, duplicate or missing runs, and late timeout
corrections.
* Added coverage for partial invocation failures and Slack notifications
when repository details are unavailable.
* Updated scheduler test coverage for completion handling and related
failure-accounting behavior.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Recurring automations require a cron expression, so users cannot
schedule a prompt to run once at a future time. This adds a one-shot
variant to the existing schedule trigger and scheduler.

Addresses ColeMurray#1596.

- Add nullable `schedule_once_at` storage and a millisecond
`scheduleOnceAt` API field. Creation requires either cron or a future
timestamp; schedule mode is fixed after creation. A paused one-shot can
be rescheduled and resumed explicitly.
- Atomically record the scheduled invocation/runs and consume the
one-shot slot. Keep it enabled while its runs are active or retrying,
then disable it once they settle. Check the current row during admission
so cancellation and concurrent or stale tick delivery cannot launch a
second session. Keep an overlapping slot due until its skip is durably
recorded.
- Exclude disabled one-shots with completed/skipped scheduled
invocations in SQL before cursor pagination, using the existing derived
invocation status. Preserve active, failed, partial-failed,
unauthorized, and never-fired entries, and retain history/detail reads.
- Add the Run once date/time choice, cancellation guidance, and
list/detail descriptions. Preserve an unchanged timestamp during edits,
including seconds and daylight-saving offsets. Preserve the configured
timezone on edits and share schedule descriptions between list and
detail.
- One-shot and recurring schedules share launch retries. Retried
attempts reuse the same run and invocation, recheck authorization, and
respect pause/delete/retry-limit changes. Rescheduling a one-shot
withdraws retries for its old slot. Keep admission, skip history, and
slot consumption in shared atomic statement binders; recovery repairs
missed final disables.

### Before/after evidence

At main `02a6bbf1c2720699444d91867026e8b0111ed6fc`, the executable
creation regression below failed: expected HTTP 201, received HTTP 400.
It now passes and asserts the exact stored timestamp and next run time.

```sh
npm test -w @open-inspect/control-plane -- src/routes/automation-create.test.ts --maxWorkers=1 -t 'creates a one-shot schedule'
```

### Validation

- 326 targeted control-plane unit tests passed across scheduler,
invocation services, store, and create/update/lifecycle routes.
- 122 real-D1 integration tests passed across one-shot scheduling,
launch retries, scheduler behavior, and invocation admission. The retry
suite runs against both recurring and one-shot schedules, including
concurrency, cancellation, rescheduling, retry exhaustion,
authorization, and missed-finalization recovery.
- 72 web form and policy tests passed, including retry selection for
one-shots and timezone/instant preservation.
- Control-plane and web type checks, Worker/Node builds,
ESLint/Prettier, SQL portability, and `git diff --check` passed.
- Verified the local Run once form with fixture API responses at a
1512×982 viewport: selected Up to 2 retries and a valid time; creation
became enabled. Uploaded screenshot artifact
`2b4c02a5e069ef415d3aba17638ade20`. No live automation was submitted.

### Limits

The existing scheduler runs every minute: a one-shot becomes eligible at
its timestamp and launches on an eligible tick, subject to the existing
work budget. It does not add an exact-second timer. D1 tests exercise
real database/session-index writes with a session-runtime stand-in; they
do not start a Modal sandbox. No deployment or issue-state change was
performed.


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

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

## Summary by CodeRabbit

* **New Features**
* Scheduled automations can now run once at a chosen date and time, in
addition to recurring schedules.
* Create and edit forms support one-time scheduling, show times in the
configured timezone, and validate that new schedule times are in the
future.
* One-time schedules support retries and can be canceled by pausing or
deleting the automation. They disable after active runs and retries
finish.
* **Bug Fixes**
* Resuming a one-time schedule preserves its configured time and reports
conflicts if the schedule changes during resume.
* One-time schedules no longer launch repeatedly after their scheduled
run is consumed.
* **Documentation**
* Updated scheduling guidance to cover one-time schedules, retries,
cancellation, and rescheduling.
<!-- 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>
The scheduler factory currently assembles an ad hoc admission object
whose methods forward to functions with a separate dependency bundle.
Replace that layer with a constructor-injected
`AutomationLaunchAdmissionService` that owns target snapshotting,
team-grant checks, and provider-auth validation.

Invocation handling continues to depend on the public two-method
`LaunchAdmissionOperations` contract. The existing low-level adapters
remain in the factory. Initial-launch and retry admission keep their
distinct ordering, including grant-version capture before target
resolution, initial attribution after insertion, and retry
attribution/provider resolution before claiming.

Migrate admission tests to the service and add coverage for fresh
provider routing on successive admissions and provider-resolution
failures. The extracted algorithm bodies are unchanged apart from
accessing constructor dependencies.

Validation:
- 188 targeted unit tests passed across launch services, invocation
services, scheduler, and factory.
- 27 real-D1 integration tests passed across automation launch retries
and team grants.
- All four control-plane TypeScript configurations passed.
- Worker and Node builds, changed-file ESLint/Prettier, and `git diff
--check` passed.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
* Updated automation launch processing to resolve targets and check
repository access and provider configuration before execution. Existing
access denials and configuration errors continue to be handled, and
provider routing is refreshed for each admission. These changes do not
add new end-user features.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- Stop idle automation, GitHub bot, and agent-child sandboxes after 2
minutes by default, while preserving the regular 10-minute default for
user, Slack, and Linear sessions.
- Resolve the session source on each alarm, inactivity scheduling call,
and idle-queue redrive; sessions without a persisted row use the regular
timeout.
- Add `SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS` with a 60-second
minimum. Invalid values warn and fall back to the default; unattended
sessions use the smaller of this setting and the regular timeout,
allowing the shorter policy to be disabled.
- Preserve connected session-page extensions, processing-heartbeat
activity renewal, and follow-up sandbox recovery.
- Emit `sandbox.inactivity_timeout` before provider-specific shutdown
with `spawn_source`, `timeout_ms`, and `idle_ms`.
- Wire the setting through Node, Terraform, and both deployment workflow
jobs; update the example environment, lifecycle/operator docs, debugging
reference, runtime comment, and changelog.

## Verification
- 184 targeted control-plane unit tests passed across lifecycle
decisions, manager scheduling/redrive, inactivity effects, cross-path
effects, reconnects, and Node configuration.
- 53 control-plane integration tests passed across sandbox events,
runtime composition/configuration, and shutdown recovery. Includes a
quiet processing turn kept alive by heartbeats beyond the unattended
timeout.
- 23 docs content-policy/inventory tests and 27 Terraform
workflow-contract tests passed.
- Repository-wide `npm run typecheck` and `npm run lint` passed.
- Prettier, `git diff --check`, and recursive `terraform fmt -check`
passed.
- `terraform validate` and all 5 targeted
`sandbox_inactivity.tftest.hcl` tests passed.

## Operational Notes
- Only the session page's WebSocket grants connected-client extensions;
direct terminal, VS Code, or VNC access alone does not.
- Existing sessions resolve their source-aware timeout on their next
alarm check.
- Inactivity dashboards using the old unmanaged-only `sandbox.timeout`
event should migrate to `sandbox.inactivity_timeout`.

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

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

## Summary by CodeRabbit

* **New Features**
* Automation, GitHub bot, and agent sessions stop after 2 minutes of
inactivity by default; user, Slack, and Linear sessions retain the
10-minute default.
* Configure the unattended-session timeout with
`SANDBOX_UNATTENDED_INACTIVITY_TIMEOUT_MS` (minimum 1 minute). The
effective timeout is capped at the regular inactivity timeout.
* Connected session pages and active runtime heartbeats extend activity.
Follow-up prompts can resume an existing session after an inactivity
stop; direct terminal, VS Code, and VNC connections alone do not extend
the timer.
* **Documentation**
* Updated setup, lifecycle, and deployment guides with timeout settings
and 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
- Move Boat snapshot saves, reads, and deletes in the control-plane
client and base builder to `/saved-snapshots`, and rename the associated
helpers, types, schemas, and constants.
- Update callers, mocks, response-envelope fixtures, documentation,
Terraform wording, and the changelog to use saved-snapshot terminology.
The allowance error now directs operators to add money to the Boat
wallet.
- Add regression coverage for the Python builder's request paths,
payload, and save polling, plus preservation of 402 `credit_required`,
404, and 409 `save_in_progress` handling.

## Compatibility and Rollout
- Request bodies, timeouts, snapshot naming formats, Terraform
variable/output/resource names, and the `BOAT_BASE_SNAPSHOT` binding are
unchanged.
- Existing snapshots need no migration or deployment ordering. The
existing prune logic is untouched.
- The changed build inputs trigger a base snapshot rebuild on the next
Terraform deployment. That save needs available allowance or wallet
funding before the Workers switch.
- Historical changelog entries retain their original wording; the legacy
terminology scan finds no other occurrences.

## Validation
- Shared package build and control-plane typecheck passed.
- Targeted Boat REST client, lifecycle, provider, image-build resources,
and adapter tests: 126 passed.
- Python provider-adapter, deployment-retention, and bundle/hash tests:
137 passed.
- Production Terraform validation and `terraform test
-filter=tests/boat_backend.tftest.hcl`: 9 passed.
- ESLint, Ruff lint/format, Prettier, changed-file Terraform formatting,
shell syntax, and `git diff --check` passed.
- No live deployment or Boat API mutation was performed.

Fixes
[COL-287](https://linear.app/colemurray/issue/COL-287/move-boat-snapshot-calls-off-the-deprecated-named-snapshots-endpoints).

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Boat snapshots are now saved and managed through the saved-snapshot
API, including for base builds and prebuilt images.
* Existing snapshots require no migration; the next deployment rebuilds
the base snapshot once.

* **Documentation**
* Updated Boat setup, configuration, and troubleshooting guidance to use
“saved snapshot” terminology.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- Materialize the shared `eligible_sessions` CTE once so recursive
re-rooting can index the filtered set instead of rescanning the viewer's
session history for every recursive row.
- Require materialization for snapshot, category-page, and
subtree-marking queries through the existing shared helper; leave
unscoped bulk marking flattenable. A short comment documents the
recursive planner requirement.
- Add a real-SQLite regression test using the full D1 migration set and
one batched session insert. It pins materialization, rejects
base-session searches/scans directly inside the recursive step, and
verifies categories, re-rooting, unread state, descendants, and
pagination on a known shape.
- No migrations, indexes, write-path changes, or polling changes.

## Local Benchmark
Measured on a synthetic in-memory SQLite database with 2,026 sessions,
including roughly 200 children beneath an archived parent and nested
descendants. Both variants ran the snapshot SQL captured from
`SessionInboxStore` with identical `mine`/all-scope bindings against the
same database. The throwaway benchmark script is outside the repository.

| Variant | Median Query Time |
| --- | --- |
| Inlined eligible CTE | 45.10 ms |
| Materialized eligible CTE | 6.24 ms |

**7.23x faster** across 20 measured runs per variant after three
warmups, alternating execution order. All 25 returned rows and their
order were identical. Measurements include statement preparation and
execution, not API or metadata-loading overhead. SQLite version: 3.53.4.

The original recursive step searches `sessions` through the viewer's
user index. With the fix it scans `rerooted_sessions` and searches
`child` using an automatic covering index on `parent_session_id`.

## Validation
- Confirmed the new query-plan regression fails on the missing
materialization assertion when `MATERIALIZED` is removed, then restored
the fix.
- `npm run test -w @open-inspect/control-plane --
src/db/session-inbox-store.plan.test.ts src/routes/session-index.test.ts
--maxWorkers=1`: 70 tests passed.
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/session-inbox.test.ts
test/integration/session-inbox-scoping.test.ts
test/integration/session-inbox-mark-read.test.ts
test/integration/session-scope-routes.test.ts
test/integration/session-shadow-audit.test.ts --maxWorkers=1`: 97 tests
passed, with existing suites unchanged.
- `npm run typecheck -w @open-inspect/control-plane`: all four
TypeScript programs passed.
- `npm run lint -w @open-inspect/control-plane`: passed.
- `npm run lint:sql-portability`: passed.
- `npm run format:check`: passed on a clean export of the staged commit.
In the live sandbox, this command reports an unrelated formatting issue
in the pre-existing, Git-excluded generated
`.opencode/tool/_memory-contract.js`; that tooling was left untouched.
- Confirmed snapshot, page, and subtree-marking statements materialize
the eligible CTE, while unscoped bulk marking does not. The expanded
real-SQLite regression rejects both materialization and the extra
eligible-set scan for unscoped marking, checks the written read state,
and fails with the previous unconditional fence.

## Post-Deploy Verification
Operator verification requires Cloudflare credentials and was not run.
Check D1 query analytics for lower inbox rows read and engine duration,
lower `GET /sessions/inbox` SQL-engine and request durations, and
reduced p90 contention on unrelated routes during inbox refreshes.

Tracks
[COL-290](https://linear.app/colemurray/issue/COL-290/inbox-query-materialize-eligible-sessions-so-re-rooting-stops).

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

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

## Summary by CodeRabbit

* **Performance**
* Improved the efficiency of loading session inbox snapshots and marking
sessions as read. Session groups, pagination behavior, and read-state
results remain unchanged.
<!-- 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
- Start the sidebar inbox from the hydrated stored team selection once
current-user authorization is available, without waiting for
memberships.
- Validate provisional team IDs with the shared query parser and derive
All teams access from role and suspension state.
- Switch to validated filters when memberships resolve, preserving the
existing SWR key when the selection is unchanged and refetching once
when a stale selection is corrected.
- Offer Mark all as read only after memberships validate the scope its
confirmation names.
- Reconcile a stored Workspace or All teams selection to All my teams
when the user has no active team. Their lists were already unfiltered
and the switcher is hidden, so the provisional request now corrects once
instead of on every load.
- Preserve membership-error handling, creator filtering, pagination, and
scoped mark-read actions.

## Tests
- The new regression tests fail before the fix, and the strengthened
membership-failure cases fail with the original loading gate restored.
The archived-only Workspace and All teams handoffs, the zero-team
storage check, and the pending-memberships Mark all as read case also
fail before their fixes.
- `npm run test -w @open-inspect/web --
src/hooks/use-active-team.test.tsx
src/hooks/use-sidebar-sessions.test.tsx
src/components/session-sidebar.test.tsx --maxWorkers=1` passed: 115
tests.
- `npm run test -w @open-inspect/web` passed: 2,735 tests across 226
files.
- `npm run typecheck -w @open-inspect/web` passed.
- `npm run lint` and `npm run format:check` passed.
- `git diff --check` passed.

## Post-Deploy Verification
- Confirm a cold-load inbox request starts alongside the memberships
request rather than after it.
- Confirm a valid stored selection causes one inbox request.
- Confirm a stale stored team, or a stored Workspace selection for a
user with no active team, causes one provisional request followed by one
unfiltered request, then storage is corrected to All my teams.
- Confirm a non-admin with All teams stored never sends `scope=all`.
- Confirm Mark all as read appears only after the team selection
resolves.

Tracks
[COL-291](https://linear.app/colemurray/issue/COL-291/sidebar-fetch-the-inbox-without-waiting-for-apimeteams).


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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Inbox and sidebar sessions can now appear while team information is
still loading, using the saved team or workspace selection where access
allows.
* Session results update to reflect validated team access once
memberships load; unavailable or invalid selections fall back to
unfiltered results.
  * “Mark all as read” is hidden until team loading finishes.

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

---------

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

## Problem

The composer's session-target picker confirms its default target only
after both `/api/repos` and `/api/environments` return. Three read-only
requests wait for that confirmation, even though their keys are usually
known from the start:

- the default repository's branch list,
- the image-build feed (`/api/image-builds`),
- the skill resolution preview.

So on a cold home-page load they run as a second serial tier after the
catalogs, and the branch list is typically the last request to settle.

## Change

**`use-session-target-picker.ts`**
- `readStoredSessionTarget()` reads the stored last selection with a
try/catch. It accepts only a `none` target, an environment with a
non-empty id, or a repository whose full name passes
`parseRepositoryFullName`.
- `anticipateDefaultTarget(...)` returns the effect's first two choices
in order: the team default environment, then the stored target (never
"none" inside a team). A catalog that is still loading counts as
containing the candidate. It returns null when the default would be
`repos[0]`, because that can't be known before the catalog loads.
- The default-target effect now calls the same helper with the loaded
catalogs, followed by its existing `repos[0]` / "none" fallbacks. The
precedence lives in one place, and the effect's storage read is now
guarded too.
- `anticipatedTarget = hasExplicitSelection ? sessionTarget :
sessionTarget ?? anticipateDefaultTarget(...)`. A loaded catalog that
confirms the guess keeps returning it, so request keys don't flip to
null while the effect commits.
- Branches are keyed on the anticipated repository. The branch selector
still renders only for a confirmed repository, and once a target is
confirmed the two are the same.
- The image-build feed is enabled when there is an anticipated target,
or when either catalog has entries.
- A new `anticipatedRequestFields` is exposed for read-only prefetches.

**`page.tsx`**
- The skill preview is requested for `picker.anticipatedRequestFields`.
The selector, the count and the prompt suggestions show it only once the
confirmed target exists. Before that they get the same null /
not-loading / empty-ready values as today.
- Warming and launching still use only `buildRequestFields()`, the
confirmed target.

### Small deviation from the ticket

`anticipateDefaultTarget` takes `loadingRepos` and `loadingEnvironments`
separately instead of a single `catalogsLoading` flag. Each candidate is
treated as available only while *its own* catalog is loading. This
avoids guessing a stored repository that the already-loaded repository
catalog has ruled out.

## Wrong guesses

SWR caches each response under its own key, and every consumer reads the
current key, so nothing needs aborting; all three requests are read-only
and authorized by the server. A stale stored target costs at most one
branch-list request and one skill-preview request. After that the effect
picks the fallback and saves it to storage. The anticipated target is
never used to warm or launch a session.

## Tests

- `use-session-target-picker.test.ts`: `useBranches` and
`useImageBuilds` are now `vi.fn`s. New cases cover:
  - a stored repository while the catalogs load;
  - a team default environment while loading;
- a stored repository that no longer exists, switching to the loaded
fallback;
  - no empty branch key while the catalogs confirm the guess;
  - an explicit selection made during loading;
  - an unparseable stored value and throwing storage;
  - a stored "No repository" in a team context.
- `page.test.tsx`: the fixture's `useSkillResolutionPreview` is now a
`vi.fn`. A new test checks that the preview is requested for the stored
target while repositories load, and that the selector shows no count
until the target is confirmed.
- With the source changes reverted, all 9 new tests fail.

Checks run:
- `npm run test -w @open-inspect/web --
src/hooks/use-session-target-picker.test.ts
"src/app/(app)/(sidebar)/page.test.tsx"
"src/app/(app)/(sidebar)/page-team-context.test.tsx"`
- `npm run test -w @open-inspect/web` (226 files, 2744 tests pass)
- `npm run typecheck -w @open-inspect/web`
- `npm run lint`
- `npm run format:check`

## How to verify after deploy (operator)

In the web request logs for a cold load of `/` with a stored selection,
`/api/image-builds`, `/api/repos/<owner>/<name>/branches` and
`/api/skills/resolve-preview` should start in the same batch as
`/api/repos` and `/api/environments`, not after them.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Skill previews and related data can begin loading while repository and
environment choices are still being restored.
* The composer keeps showing an unresolved preview until the selected
target is confirmed, and shows the skill count once the repository is
available.
* Default and restored target choices are handled more consistently
while catalogs load.

<!-- 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>
Adds `CHANGELOG.md` entries for significant features merged since
October 14 that didn't add their own. Bug fixes, refactors, dependency
bumps, and small UI polish are left out on purpose.

| Date | Entry | PR |
|---|---|---|
| Oct 25 | One-shot scheduled automations (D1 migration 0091) | ColeMurray#2411 |
| Oct 25 | Launch retries for scheduled runs (D1 migration 0090) | ColeMurray#2510
|
| Oct 25 | Review memories in bulk, plus the shared memories **All**
view | ColeMurray#2515, ColeMurray#2503 |
| Oct 24 | Codex sessions report their cost | ColeMurray#2466 |
| Oct 24 | Mermaid diagrams in assistant messages | ColeMurray#2407 |
| Oct 24 | Duplicate an automation | ColeMurray#2499 |
| Oct 22 | Merge duplicate member accounts | ColeMurray#2477 |
| Oct 20 | OpenComputer sandbox provider removed (operator upgrade note)
| ColeMurray#2450 |

Each entry goes under its PR's merge date (UTC) and follows the existing
style. The wording is based on each PR's description and the public docs
pages it updated. The file was formatted with Prettier.

Not included, but could be added: Codex plan updates and error surfacing
(ColeMurray#2459, ColeMurray#2460), marking a parent session and its children read (ColeMurray#2464),
PR state on session search results (ColeMurray#2461), and the
`bridge.harness_attached` → `bridge.harness_attach` log rename (ColeMurray#2385).

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added one-shot scheduled automations, bulk memory review, and
automation duplication.
  * Added Codex cost reporting and Mermaid diagram rendering.
  * Added account merging, with permission requirements.
  * Updated shared-memory scope.

* **Improvements**
  * Scheduled automations now support bounded launch retries.

* **Breaking Changes**
* OpenComputer support has been removed. Terraform validation fails for
deployments that still use it.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- Delegate authentication to existing control-plane requests instead of
performing a separate server-side session lookup first.
- Preserve authentication-before-provider-disclosure for unsupported
repo images in a shared server-only helper.
- Preserve 401 responses in the image-build feed and Slack channel
route, including mixed image-feed failures, while retaining Slack's
non-authentication fallback.
- Reject attachment uploads without a session cookie before reading
their bodies, without making a network request.
- Retain explicit session lookups for the login page and local
unsupported-provider responses, preserve route-specific cache/error
behavior, and remove obsolete websocket auth timing.

## Validation
- `npm run build -w @open-inspect/shared`
- `npm run test -w @open-inspect/web -- src/app/api
src/lib/server-auth-boundary-eslint.test.ts --maxWorkers=1` (180 tests
passed)
- `npm run test -w @open-inspect/web -- --maxWorkers=1` (2,780 tests
passed)
- `npm run typecheck -w @open-inspect/web`
- `npm run lint`
- `npm run format:check -- '!.opencode/tool/_memory-contract.js'`
passed. The unfiltered command flags this unrelated, git-ignored
sandbox-generated helper; it was left unchanged.
- Confirmed the new regressions fail against the original route
implementations and pass with these changes.
- Confirmed no `app/api/**/route.ts` references `getServerAuthSession`.

## Behavior Notes
- Malformed anonymous requests can now encounter local validation before
control-plane authentication.
- Some unauthorized responses use the route's existing control-plane
error mapping; clients continue to receive HTTP 401.
- No control-plane, schema, infrastructure, or login-page behavior
changes.

## Post-Deploy Verification
Operator verification requires production access and was not performed
here. Compare function durations for environment, repository,
image-build, and branch requests, and confirm control-plane
session-lookup traffic decreases. Browser-owned session refresh requests
should remain.

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

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

## Summary by CodeRabbit

* **API Behavior**
* Many API requests now rely on the control plane to determine
authentication and return authorization errors.
* Session attachment uploads require a valid browser-session cookie;
existing validation and size limits remain.
* Repository image operations retain unsupported-provider restrictions,
and image-build authorization errors are preserved.
* Session inbox read-state requests require valid JSON and a matching
request origin.
* Sandbox access requests return an empty response when access is
unavailable.
* Invalid resource paths are rejected, and resource identifiers are
safely encoded when forwarded.
  * Automation key regeneration rejects request bodies over 16 KiB.
* **Tests**
* Updated API tests cover forwarded authorization responses and revised
request handling.
<!-- 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>
…oleMurray#2550)

## Summary

Two scheduling changes were documented on the public site
(`automations/schedules.mdx`) but not in the internal reference
`docs/AUTOMATIONS.md`, which still described schedules as cron-only:
- ColeMurray#2411: one-shot scheduled automations
- ColeMurray#2510: retrying scheduled runs whose launch fails before the prompt

This brings `docs/AUTOMATIONS.md` in line:
- **Intro and trigger tables:** schedules run once or on a cron cadence,
and the schedule fields now include **Retries**.
- **Schedule Options:** split into **Run Once** and **Recurring
Schedules**. Run Once covers:
  - the **Run at** field and the `scheduleOnceAt` API field
  - when the slot is consumed
  - auto-disable once its runs settle
  - cancelling, rescheduling and resuming
  - fired one-shots disappearing from the list
- **New Launch Retries section:**
  - which failures are retried and which never are
  - the 2-minute doubling backoff
  - attempts and re-authorization
  - how retries count toward auto-pause
  - cancellation
- **Pause/Resume, Edit, Run Statuses and Limits:** one-shot behavior,
the **Retrying** status, and the retry limit.

## Verification
- Checked against:
- `MAX_AUTOMATION_RETRIES`, `scheduleOnceAt` and the `retrying` status
in `packages/shared/src/types/automations.ts`
  - `LAUNCH_RETRY_BASE_DELAY_MS` in `scheduler/launch-policy.ts`
- the **Schedule**, **Run at** and **Retries** fields in
`automation-trigger-fields.tsx`
  - the public `schedules.mdx`
- Prettier is clean (lint-staged ran on commit).

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

---------

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

ColeMurray#2407 made the session timeline render completed ` ```mermaid ` blocks
in assistant responses as diagrams, but nothing told users. This adds a
short paragraph below the timeline table in `sessions/session-page.mdx`:
- what renders
- the "View diagram source" disclosure
- that a diagram which can't be rendered stays as code
- a tip to ask the agent for a diagram when a flow is easier to read as
a picture

It's a paragraph rather than a table cell so Prettier doesn't reflow the
whole table. `mermaid-code-block.tsx` is added to `relatedCode`.

## Verification
- Label and fallback behavior checked against
`packages/web/src/components/mermaid-code-block.tsx`.
- `npm test -w @open-inspect/docs` passes. Prettier is clean.

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

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

ColeMurray#2499 added a **Duplicate** button to the automation detail page, but no
docs mentioned it. This adds it in two places:
- `automations/first-automation.mdx`: a **Duplicate** bullet in the
"Pause, resume, edit, duplicate, or delete" step.
- `docs/AUTOMATIONS.md`: a **Duplicate** section under Managing
Automations.

Both cover what matters when making a copy:
- The copy opens in the create form as `<name> (copy)` and is saved only
on **Create Automation**. It starts with no run history and with you as
executor.
- Webhook keys are never copied (new ones are issued), and the form asks
for the Sentry client secret again.
- If you can't create automations in the source's team, the copy starts
in your current scope with no targets. Repositories or environments you
can't use are left out. A notice above the form explains either case.
- The button only appears when you can create automations.

## Verification
- Checked against `automation-form-policy.ts`
(`createAutomationDuplicate`, the `(copy)` suffix, keys and secret not
copied), `use-automation-duplicate.ts`, the create page's notices, and
the detail page's `useCanCreateAutomation` gate.
- `npm test -w @open-inspect/docs` passes. Prettier is clean.

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

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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Documentation**
* Updated the automation guides to explain how duplication opens a
prefilled form and saves a separate automation without run history.
* Clarified that copies use the current user as executor, regenerate
webhook keys, and require the Sentry client secret to be entered again.
* Documented how the form handles unavailable teams, repositories, and
environments, including notices when it uses a different scope or omits
unavailable targets.

<!-- 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

Codex sub-agents now show and behave the way the Claude and OpenCode
harnesses' sub-agents already do. Each sub-agent appears as a `task` row
with its activity grouped underneath, and the prompt finishes only once
its sub-agents have finished. All changes are in `harness/codex/`; the
bridge contract, control plane and web app are unchanged.

### Why this is needed now

Six of the eight Codex models we offer (`gpt-6.1-sol` (the default),
`gpt-6-sol`, `gpt-6-astra`, `gpt-6-luna`, `gpt-5.6-sol`,
`gpt-5.6-terra`) declare Codex's v2 multi-agent runtime. For these
models Codex offers the `collaboration` tools (`spawn_agent`,
`wait_agent`, `followup_task`, …) regardless of
`features.multi_agent=false`.

So on `main`, a Codex session can already spawn sub-agents. Today:
- their activity and token usage are dropped;
- their OpenInspect tool calls are rejected;
- the prompt can finish while they keep running in the sandbox.

This was verified against Codex 0.163.0 with a local mock Responses API,
including `model/list` output and the harness's exact launch arguments.
No setting found in 0.163.0 turns the v2 tools off.

## Behaviour

- **Task rows.** A parent-thread `subAgentActivity` (`started`) becomes
a `tool_call` with `tool: "task"`:
  - `callId` is `codex:<turn>:agent:<thread>`;
  - `args` are `{description: <task name>, task_id: <agent path>}`;
  - `childSessionId` is the sub-agent's thread id.
- **Grouping.** The sub-agent's own tool calls use the existing
projection, tagged `isSubtask` / `taskCallId` / `childSessionId`, so the
timeline groups them under the task. Its assistant text is not streamed;
its final message becomes the task's `output`. A failed or interrupted
sub-agent marks the task as `error`.
- **Settlement.** The prompt finishes only after the parent turn and
every sub-agent turn rooted at it (0.163.0's `rootTurnId`) have ended.
This matches the Claude harness, which disables background sub-agents.
If the parent turn is interrupted or fails, its running sub-agents are
interrupted. `abort()` interrupts both the parent and its sub-agents.
Containment through `stop_execution` is unchanged, because sub-agents
run inside the same app-server process.
- **Usage.** Each sub-agent turn reports its own `step_start` /
`step_finish` (subtask fields, `stepId codex:<thread>:<turn>`), which
lands in `step_usage` with `is_subtask`. Usage is tracked per thread:
  - a newly spawned thread starts from zero;
  - a sub-agent re-tasked by a later prompt is billed only for new work;
- an unknown or regressing baseline is not billed, the same rule the
parent follows.
- **OpenInspect tools in sub-agents.** `item/tool/call` from a running
sub-agent turn of the current prompt is now accepted, as sub-agents can
in the other harnesses. A tool call can arrive before the notification
that starts its turn has been processed, so ownership waits briefly for
it.
- **Limits.**
- **Depth.** One level, matching the timeline's one-level grouping. A
sub-agent's own sub-agents are interrupted as soon as their turn starts.
In v2, Codex ignores `agents.max_depth`, and in testing a chain of
sub-agents recursed to roughly 50 levels.
- **Concurrency.**
`features.multi_agent_v2.max_concurrent_threads_per_session=4`, which
counts the parent thread. This pins today's default explicitly; setting
it does not enable v2 for other models.
- **v1 runtime.** It stays disabled (`features.multi_agent=false`). The
comment now explains what the flag does and doesn't control. `gpt-5.5`
and `gpt-5.6-luna` get no sub-agents, as before.

## Changes

- `harness/codex/subagents.py` (new): `CodexSubagents`, a pure projector
for one prompt's sub-agents (registry, task and nested events,
per-thread usage, refusal of nested sub-agents, ownership).
- `harness/codex/events.py`: the tool-item projection and usage math are
extracted into `tool_call_event()` and `project_usage()` so the parent
and sub-agents share them. The parent's behaviour is unchanged, and the
existing 97 event tests pass. `failure_message` is now public.
- `harness/codex/__init__.py`: the turn loop runs until sub-agents
settle; adds interrupt fan-out, sub-agent tool ownership, `abort()`
fan-out, and distrust of sub-agent usage after an abnormal exit.
- `harness/codex/env.py`: thread cap and flag comment.
- Tests:
  - `tests/test_codex_subagents.py`: projector.
- `tests/test_codex_subagent_lifecycle.py`: settlement, interrupts,
nested-sub-agent refusal, tool ownership race, abort, abnormal exit.
- A native end-to-end test in `tests/test_codex_native.py`. The scripted
Responses fixture can now route items to sub-agent requests using the
`x-openai-subagent` header.

## Testing

`OI_TEST_CODEX_BINARY=/usr/local/bin/codex uv run --extra dev pytest
tests/test_codex_*.py tests/test_bridge_harness_lifecycle.py` against
the pinned **0.163.0** binary: 270 passed, 1 skipped. The skip is the
existing cross-VM restore test, which needs a snapshot fixture. `ruff
check` and `ruff format` are clean.

The native test runs a real spawn → sub-agent OpenInspect tool call →
`wait_agent` cycle. It checks:
- the completed task row with the sub-agent's answer;
- the nested tool call;
- 240 sub-agent tokens reported separately from the parent's 360.

## Known limits / follow-ups

- **No "Instructions" disclosure.** The sub-agent's prompt is not
available in v2 events: there is no `userMessage` item on the child
thread, and the `spawn_agent.message` parameter is declared encrypted.
- **Untested against a real model.** All verification used a scripted
Responses endpoint, not a real model or credential.
- **Tool wording.** The shared `spawn-child` description still tells the
model to use "the Agent tool" for sub-agent requests. A neutral wording
would change the Codex tool catalog hash, which resets native threads,
so it is left for a separate change.


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

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

## Summary by CodeRabbit

* **New Features**
* Codex sessions now support up to four concurrent agents, with agent
activity and nested tool calls shown as tasks and steps.
* Prompt usage and cost totals include supported agent activity, with
available token and cost details shown for individual steps.
* **Improvements**
* Prompts wait for active agents to finish, and stopping a prompt also
requests interruption of its agents.
* Tool calls are accepted only after their turn is recognized and
associated with the active prompt.
<!-- 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
- Add `web_serverless_region` for the Vercel web project, retaining the
`iad1` default and leaving the docs project's region unchanged.
- Add an optional session Durable Object location hint, validate it
during Cloudflare environment composition, and share the hinted raw
dispatch between runtime calls and WebSocket upgrades. Invalid hints
warn and fall back to automatic placement.
- Wire both settings through Terraform plan/apply and document placement
selection, verification, redeployment requirements, and rollback.
- Keep the Worker-only setting out of the Node host's configuration
reader while preserving its exhaustive compile-time configuration check.

Closes COL-305.

## Validation
- Confirmed new runtime, parser, WebSocket, workflow, and Terraform
wiring/validation regressions fail without their corresponding changes.
- Terraform recursive formatting check, backend-disabled initialization,
validation, and all production Terraform tests: passed (91 tests).
- `npm run test:terraform-workflow-contract`: passed (29 tests).
- `npm run build -w @open-inspect/shared`: passed.
- Targeted control-plane tests, including Cloudflare dispatch and Node
configuration contracts: passed (34 tests).
- Worker lifecycle boundary integration tests: passed (6 tests).
- Real sandbox WebSocket upgrade integration test: passed, preserving
the `101` response.
- Full `npm run typecheck -w @open-inspect/control-plane` and `npm run
lint`: passed.
- `npm run format:check` reports a pre-existing ignored generated
helper, `.opencode/tool/_memory-contract.js`. Checking formatting with
only that helper excluded passes; the helper is not part of this PR.

## Operator Verification
No production settings were changed. Enable one setting at a time after
merge and deployment.

1. Apply the web region override, then redeploy the web app so its build
pulls the updated Vercel project settings. Verify the function location
in Vercel Runtime Logs (Function details) or `proxy.lambdaRegion` in log
drains and compare browser/request timings and Worker telemetry
(`$workers.event.request.cf.colo` on `cf-worker-event`).
2. Apply the session location hint, create a new session, and verify its
placement using GraphQL `durableObjectsPeriodicGroups` grouped by
`coloCode` and filtered by `objectId`. Compare new and existing session
timings, including sandbox event latency.
3. To roll back, unset the relevant override, apply, and redeploy the
web app if its region changed. Existing Durable Objects do not move;
location hints are best-effort and affect only newly created objects.

Worker Smart Placement, D1 placement/replication, and sandbox-provider
regions remain out of scope.

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

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

## Summary by CodeRabbit

- **New Features**
- Added configurable deployment regions for the web application and
session services. The web application defaults to `iad1`; session
placement remains automatic unless a supported location is configured.
- Session location settings apply to newly created session objects.
Existing objects stay in their current locations, including after the
setting is changed or removed.
- **Documentation**
- Added guidance on supported region values, deployment timing,
verification, and rollback 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>
…eMurray#2538)

## Summary
- Cache validated branch lists per case-insensitive repository key using
the existing engine-neutral cache store and SCM identity fingerprint.
- Serve entries fresh for 60 seconds, retain them for one hour, and
refresh stale entries in the background. Cold requests await the same
refresh promise registered with the background-task context so a
disconnected caller can still populate the cache.
- Include `cached` and `cachedAt` in branch responses and record
`kv_read_ms` plus `scm_api_ms` when fetching. Provider failures are
never cached; background failures leave stale data intact, and
synchronous errors retain their existing responses.
- Use GitHub's first-page `Link` last-page number to fetch later pages
concurrently, preserving page order and the 500-branch limit. Keep
sequential pagination when no usable last-page link exists. Link URLs
are never followed.

## Behavior And Scope
- Newly pushed branches become visible after roughly the 60-second fresh
window plus a request to trigger revalidation and refresh completion.
Cross-location KV propagation can add delay. The picker still searches
only fetched branches.
- Route authorization remains ahead of cache access. Repository-list
caching behavior and GitLab pagination are unchanged. Concurrent
cold/stale branch refreshes share one in-flight promise per repository
and SCM identity within each isolate; entries are released on success or
failure.
- SQL cache writes reclaim expired rows, including abandoned repository
keys. Reads remain non-writing, and live/permanent entries are retained.
Idle caches retain expired rows until their next write but cannot grow
while idle.
- A dedicated `repos_branches_cache.fetch` event reports SCM duration
independently of HTTP request completion, including background and
failed fetches.

## Validation
- Passed: 131 focused tests across branch routes, cache entries, GitHub
pagination/token caching, SQL expiry reclamation, Node host/cache
initialization, and Node cache conformance (`--maxWorkers=1`).
- New concurrent-refresh, expiry-reclamation, and background-timing
regressions reproduced before their fixes.
- Confirmed the new cache-hit/stale-refresh/identity behavior tests and
concurrent-pagination regression tests fail against the original
implementations before applying the fixes.
- Passed: `npm run typecheck -w @open-inspect/control-plane` (all four
TypeScript programs).
- Passed: `npm run lint -w @open-inspect/control-plane`.
- Ran `npm run format:check`; its only failure is the pre-existing
ignored runtime tooling file `.opencode/tool/_memory-contract.js`, which
was left untouched. Prettier checks pass for all changed files.
- Passed: `git diff --check` and commit hooks.
- Passed: `npm run test:integration -w @open-inspect/control-plane --
test/integration/cache-store-conformance.test.ts --maxWorkers=1` (26
passed, one expected KV-expiry skip).
- Attempted the full integration suite with one worker; it exceeded the
10-minute command limit after workerd teardown/reset errors. The
isolated cache integration run passed.

## Post-Deploy Verification
Production verification requires operator credentials. Check that
repeated branch requests within the fresh window omit `scm_api_ms`, that
stale requests return promptly while refreshing, and that branch-route
and home-page completion latency improve without changing authorization
or error behavior.

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

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

## Summary by CodeRabbit

* **New Features**
* Repository branch listings now support caching for GitHub and GitLab.
Fresh results can be reused, while older cached results remain available
during background refreshes.
* Branch listing responses now include whether results came from cache
and when they were cached.
* **Improvements**
* GitHub branch listings now retrieve multiple pages, up to 500
branches, to include more results for repositories with large branch
lists.
* Expired cache entries are reclaimed during successful writes, helping
limit stale cache data.
<!-- 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>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Terraform Validation Results

Validation job: success
Check Secrets job: success

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

Note: Terraform plan was skipped because secrets are not configured. This is expected for external contributors. See docs/GETTING_STARTED.md for setup instructions.

Pushed by: @jasoncuriano, Action: pull_request

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.

8 participants