Skip to content

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

Merged
jasoncuriano merged 246 commits into
mainfrom
sync-upstream-d002c486
Aug 26, 2026
Merged

chore: sync fork with upstream (ColeMurray/background-agents)#17
jasoncuriano merged 246 commits into
mainfrom
sync-upstream-d002c486

Conversation

@jasoncuriano

Copy link
Copy Markdown
Collaborator

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

  • 245 upstream commits, 1118 files changed
  • Merged tree is byte-for-byte identical to upstream/main — no fork-local changes included
  • package-lock.json left untouched (npm's only local change was devdevOptional flag churn, reverted)

Verified locally before push:

Check Result
npm run build (all packages) pass
npm run typecheck pass
npm test -w @open-inspect/control-plane 3184 passed / 205 files
All five worker dist/index.js artifacts present

🤖 Generated with Claude Code

ColeMurray and others added 30 commits August 5, 2026 11:08
## Summary
- define the canonical GitHub and Google issuer mapping in
`@open-inspect/shared`
- persist `provider_issuer` from both `UserStore` identity insertion
paths while leaving Slack and Linear issuers null
- expose the stored issuer through `UserIdentity` and reuse the shared
constants in sign-in provider code
- add D1 migration `0056` to backfill GitHub and Google identities
created with null issuers
- cover both identity creation paths for all four providers

## Verification
- `npm run build -w @open-inspect/shared`
- `npm run build -w @open-inspect/control-plane`
- `npm run typecheck -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/control-plane`
- `npm test -w @open-inspect/shared -- --run
src/sign-in-provider.test.ts`
- `npm test -w @open-inspect/control-plane` (153 files, 2,311 tests)
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/user-store.test.ts` (24 tests)
- isolated reruns of `websocket-client.test.ts` and
`session-diffs.test.ts` passed after load-related timeouts in the full
parallel integration run
- ESLint, Prettier, and `git diff --check`

Closes COL-27.

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

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

* **Improvements**
* Added canonical issuer tracking for user identities across supported
sign-in providers.
  * Improved GitHub and Google sign-in validation consistency.
* Existing GitHub and Google identities are automatically populated with
provider issuer information.

* **Reliability**
* Added validation to ensure issuer data is consistently mapped and
persisted across sign-in providers.
* Added safeguards to preserve existing issuer values and leave
unsupported provider records 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
- omit the redundant legacy `openinspect_environment` tag from Vercel
image-build sandbox requests
- preserve the shared legacy label for providers without Vercel's
five-tag restriction
- assert the exact five-tag Vercel image-build payload in the provider
test

## Context
Commit `a22e3fc` expanded image-build identity labels from four to six.
Vercel's `POST /v2/sandboxes` API permits at most five tags, causing
Vercel image-build sandbox creation to fail validation.

## Verification
- `npm test -w @open-inspect/control-plane -- --run
src/sandbox/providers/vercel/provider.test.ts`
- `npm run typecheck -w @open-inspect/control-plane`
- `npx eslint
packages/control-plane/src/sandbox/providers/vercel/provider.ts
packages/control-plane/src/sandbox/providers/vercel/provider.test.ts`
- `npm test -w @open-inspect/control-plane` (154 files, 2312 tests)
- independent sub-agent review: no findings

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

---------

Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
…oleMurray#1300)

Fixes ColeMurray#1290.

## Problem

After the Better Auth cutover (ColeMurray#1126, migrations 0047–0049), the system
ran two disjoint identity registries sharing one id space: canonical
`users`/`user_identities` (written by bot ingress) and Better Auth's
`auth_users`/`auth_accounts` (written at web sign-in). Nothing bridged
them: canonical users were locked out of web sign-in, bot-first users
kept hitting the same wall, and users split bidirectionally into phantom
rows.

## Change: consolidation, not synchronization

Per maintainer direction, this PR **dissolves the parallel registry**
instead of bridging it. Better Auth now persists directly into the
canonical tables through a custom adapter — its user model IS `users`,
its account model IS `user_identities`. A bot-created GitHub identity is
an account, so `findOAuthUser`'s account-first lookup signs bot-first
users straight into their canonical row. The entire cross-registry drift
class (stranded graphs, phantom splits, missing projections) ceases to
exist structurally.

### Custom adapter (`db/better-auth-adapter.ts`)
`createAdapterFactory` adapter on the `SqlDatabase` seam: generic SQL
executor (8 methods, all 11 where-operators), Date ⇄ epoch-ms
transforms, boolean ⇄ 0/1, `provider_issuer` injection on account
creates, blank `display_name` folding. Ids stay canonical 32-hex via the
existing `generateId` config. `encryptOAuthTokens` continues to run
above the adapter, so token columns hold ciphertext.

### Migration 0057 — one-time fold-in, then drop
- `users` gains `email_verified` (the implicit-linking gate,
`requireLocalEmailVerified`); `user_identities` gains the OAuth
credential columns + `updated_at`.
- Same-id auth rows merge into their canonical row (email/profile
backfill, guarded + `OR IGNORE` nets so no drift state can abort the
Terraform apply).
- Web-only auth rows become canonical users; their accounts fold in with
credentials.
- One-time reviewed backlog verify: every emailed canonical user
existing at cutover gets `email_verified = 1` — this is what unlocks the
ColeMurray#1290 lockout cohorts. Post-cutover, verification is minted only from
OAuth proof at sign-in.
- Strands whose email belongs to a different canonical user are
superseded (preflight-counted): their next sign-in email-links onto the
row that owns their history.
- `auth_sessions` re-keys onto `users` (epoch-ms columns);
`auth_verifications` recreated (ephemeral state);
`auth_users`/`auth_accounts` dropped. Rollback for the cutover window is
the preflight D1 Time Travel bookmark (operator runbook in the migration
header).

### Claim decorator (`auth/user/sign-in-claim.ts`)
What no framework can do: trust bot-attributed data. The OAuth callback
is the one moment a provider-verified email is in hand, so a small
decorator around `getUserInfo` backfills NULL emails, normalizes legacy
email forms, and mints `email_verified = 1` from the proof —
swallow-and-log, never failing a sign-in. Divergent multi-surface pairs
are evented (`auth.subject_email_collision`) and the sign-in lands
account-first on the subject owner; the merge script converges the pair.

### Deleted
`CanonicalUserProjection`, `AccountIdentityProjection` + all four
databaseHooks, the two-tier reconciliation decorator, the R1–R5
consistency reports, the scheduled reconciliation job, and 0057's former
runtime sweep. The merge script drops its entire auth-graph half (email
parking, surviving-email selection) — browser sessions simply re-point.
**Net −1,914 lines.**

## Behavior changes vs. the bridged design

- Slack/Linear ingress writes `email_verified = 1` (per-maintainer
decision): both platforms verify mailbox ownership (Slack confirms
address changes, Linear's email is its login credential) and the
first-party bots fetch the address server-side, so attribution from
these providers carries the same weight as OAuth proof. The attestation
is an explicit per-provider set (`EMAIL_ATTESTING_PROVIDERS`) — a future
ingress provider stays unproven unless deliberately added.
- Bot-first users with an unproven attributed email (non-attesting
providers, legacy rows) still heal at first sign-in — the incoming OAuth
proof of exactly that email mints verification instead of the linking
gate refusing them.
- A shared-subject/email split signs into the **subject owner** (the row
with their history), not the email owner — account-first is now the
natural priority. The pair remains enumerable merge work.
- `user_identities` is now the live credential store for browser-linked
GitHub/Google tokens (read via `auth.api.getAccessToken`). Better Auth's
ciphertext (keyed by `BROWSER_AUTH_SECRET`) coexists with the app's
`TOKEN_ENCRYPTION_KEY` domain used elsewhere — documented, deliberate.
- D1 has no interactive transactions, so the adapter runs statements
sequentially (`transaction: false`); with one registry and
client-generated ids, a mid-register failure self-heals at the next
sign-in instead of stranding a parallel-table graph.

## Testing

- 9 migration fold-in tests (pre-cutover schema reconstructed in-test,
real 0057 statements executed: merges, web-only creation, strand
supersession, backlog verify, credential grafting incl. the cross-owner
refusal, session re-keying, whitespace variants, unparseable timestamps)
- 11 end-to-end sign-in flows through the real worker + workerd D1 +
real Better Auth (every cohort, the collision case, legacy email
normalization, cross-provider linking, credential storage)
- 7 merge tests, updated browser-auth/callback suites, schema-alignment
assertions for the adapter's field maps
- Suites: 2,312 unit + 741 integration green; typecheck/lint clean; CLI
smoke-verified

## Rollout

Single deploy: Terraform applies 0057 before the worker. Capture the
Time Travel bookmark and preflight counts (migration header) first.
In-flight OAuth handshakes at the deploy moment fail once and retry;
sessions of superseded strands end (by design). Watch
`auth.subject_email_collision` (enumerable merge work) and
`auth.claim_failed` (should be ~never).


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

* **New Features**
* Added safer GitHub and Google sign-in account linking with
verified-email claiming and collision protection.
* Added duplicate user account merging with preview, resumable
execution, and validation.
* Added a command-line utility for previewing and executing user merges.
* **Improvements**
* Consolidated authentication records into the primary user and identity
system.
* Normalized email addresses consistently; blank values are treated as
absent.
* Preserved OAuth credentials, sessions, verification status, and
related data during migration.
* **Documentation**
* Clarified the separate encryption keys used for browser authentication
and SCM tokens.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…Murray#1237)

**Prevents persisted terminal state from producing a server/client
hydration mismatch and gives grouped settings controls accurate
accessible structure.**

## Root-cause tasks

Fixed **10 root-cause tasks**:

1. `react-doctor/no-hydration-branch-on-browser-global` -
`packages/web/src/app/(app)/session/[id]/page.tsx` - terminal visibility
read `localStorage` during initial render. The preference now uses
`useSyncExternalStore` with a stable server snapshot, preventing
hydration drift and inconsistent terminal rendering. **Human severity:
high.**
2. `react-doctor/rerender-lazy-ref-init` -
`packages/web/src/hooks/use-session-attachments.ts` - an upload cache
`Map` was allocated on every render and discarded after the first. Lazy
initialization avoids unnecessary allocations in the attachment
composer. **Human severity: low.**
3. `react-doctor/rerender-lazy-ref-init` -
`packages/web/src/hooks/use-session-participant-profiles.ts` - the
attempted-profile ID `Set` was reallocated on every render. Lazy
initialization reduces avoidable work during participant updates.
**Human severity: low.**
4. `react-doctor/rerender-lazy-ref-init` -
`packages/web/src/hooks/use-session-socket.ts` - the subscription waiter
`Set` was reallocated on every socket-hook render. Lazy initialization
avoids churn on a high-activity session path. **Human severity: low.**
5. `react-doctor/label-has-associated-control` -
`packages/web/src/components/automations/automation-form.tsx` - the
trigger-type group heading was an unassociated form label. It is now
neutral text, avoiding misleading label semantics for assistive
technology. **Human severity: medium.**
6. `react-doctor/label-has-associated-control` -
`packages/web/src/components/automations/automation-form.tsx` - the
repository configuration label did not identify its popover trigger.
Matching `htmlFor`/`id` values now associate the visible label with the
control. **Human severity: medium.**
7. `react-doctor/label-has-associated-control` -
`packages/web/src/components/settings/sandbox-settings.tsx` - Service
Ports was an unassociated label for two controls. A `fieldset`/`legend`
now exposes the controls as one named group. **Human severity: medium.**
8. `react-doctor/label-has-associated-control` -
`packages/web/src/components/settings/sandbox-settings.tsx` - Tunnel
Ports was an unassociated label for a dynamic control group. A
`fieldset`/`legend` now provides group semantics without changing the
visible heading. **Human severity: medium.**
9. `react-doctor/label-has-associated-control` -
`packages/web/src/components/settings/sandbox-settings.tsx` - Child
Sessions was an unassociated label for two limits. A `fieldset`/`legend`
now gives screen-reader users the shared context. **Human severity:
medium.**
10. `react-doctor/label-has-associated-control` -
`packages/web/src/components/settings/sandbox-settings.tsx` - Resources
was an unassociated label for CPU and memory controls. A
`fieldset`/`legend` now names that control group. **Human severity:
medium.**

All selected diagnostics were ungrouped. No non-null `fixGroupId` was
selected or split. Diagnostics with a shared `fixGroupId` remain
deferred as complete groups.

## React Doctor results

- Total diagnostics: **108 before, 98 after**
- Errors: **2 before, 1 after**
- Warnings: **106 before, 97 after**
- `no-hydration-branch-on-browser-global`: **1 → 0**
- `rerender-lazy-ref-init`: **3 → 0**
- `label-has-associated-control`: **13 → 7**
- Raw diagnostics cleared: **10**
- No new React Doctor rule sites were introduced.

## Validation

- `npm run typecheck -w @open-inspect/web` after the error fix and after
each warning task: passed
- `npm run typecheck -w @open-inspect/web`: passed
- `npm run lint -w @open-inspect/web`: passed
- `npx prettier --check packages/web`: passed
- `npm test -w @open-inspect/web`: passed, 111 files and 852 tests
- Focused automation, sandbox settings, attachment, participant-profile,
and socket tests: passed, 5 files and 100 tests
- Full React Doctor after-scan: passed, 98 diagnostics
- Changed-scope React Doctor scan against `origin/main`: no new finding;
it reports the pre-existing `prefer-useReducer` warning at
`sandbox-settings.tsx:234`
- `npm run build -w @open-inspect/web`: still fails at the same
pre-existing `/automations/new` prerender with `Cannot read properties
of null (reading 'useContext')`, digest `2120487162`; compilation and
TypeScript complete successfully before that failure

## Existing failures

- The production build failure above was reproduced before editing and
remains identical after the changes.
- The repository-wide formatter baseline reports pre-existing formatting
in `.opencode/package.json`; the web-only formatting check passes.
- The remaining `effect-needs-cleanup` error is a validated false
positive: the effect calls a callback that creates the WebSocket, and
the effect's returned teardown closes that same socket, matching the
canonical rule exclusion.

## Deferred

- Broad component splitting (`no-giant-component`) and reducer
migrations remain for later code-owner-reviewed batches.
- Grouped prop/state adjustment findings were left intact; no
`fixGroupId` was split.
- Sensitive integration/auth settings and terminal iframe sandbox policy
require human judgment.
- Locale presentation, placeholder labeling, image optimization, dynamic
chart imports, array-key identity, and migration-scale iteration/lookup
changes remain deferred where runtime or UX intent is unclear.
- Remaining custom-control label findings require component-level
accessibility decisions rather than mechanical edits.

## Visual verification

Browser visual verification was not run. The UI edits are intended to be
visually neutral semantic changes, and the terminal change affects
hydration/state synchronization rather than layout; lint, typecheck, and
focused interaction tests cover the touched behavior.

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

---------

Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
…ray#1256)

## Summary

Fixes **5 root-cause tasks** identified by the nightly React Doctor
scan.

1. **High - `react-doctor/no-hydration-branch-on-browser-global` -
`packages/web/src/app/(app)/session/[id]/page.tsx`**
The terminal panel initialized from `localStorage` during render, so
server and first-client output could disagree. Terminal visibility now
uses `useSyncExternalStore` with a stable server snapshot and
same-tab/cross-tab updates, preventing hydration mismatches without
introducing a post-paint flicker.
2. **Low - `react-doctor/only-export-components` -
`packages/web/src/components/session-list-item.tsx`**
`buildSessionHref` shared a component module, invalidating the Fast
Refresh boundary. Moving it to `session-list-item-utils.ts` lets
developers retain component state during edits.
3. **Low - `react-doctor/only-export-components` -
`packages/web/src/components/session-timeline.tsx`**
Internal event grouping helpers were unnecessarily exported from a
component module. Keeping them module-private restores a safe Fast
Refresh boundary without changing runtime behavior.
4. **Low - `react-doctor/only-export-components` -
`packages/web/src/components/settings/image-build-status.tsx`**
The ready-details formatter shared a component module. Moving it to
`image-build-status-utils.ts` preserves Fast Refresh state for image
status UI development.
5. **Low - `react-doctor/only-export-components` -
`packages/web/src/components/terminal-message-read-observer.tsx`**
The message-read decision helper shared a component module. Moving the
helper and its single attempt-limit constant to
`terminal-message-read-state.ts` keeps the component boundary
refresh-safe and preserves test coverage.

All selected diagnostics were ungrouped and therefore counted
individually. No non-null `fixGroupId` was selected or split; grouped
findings remain intact for later review.

## React Doctor

- Scanner: React Doctor `0.9.3`, schema v3, full scope
- Before: **108 diagnostics** (2 errors, 106 warnings), score 59
- After: **103 diagnostics** (1 error, 102 warnings), score 63
- Raw diagnostics cleared: **5**
- `no-hydration-branch-on-browser-global`: 1 -> 0
- `only-export-components`: 6 -> 2
- Changed-scope regression scan: no issues found, score 87
- No new full-scan diagnostics were introduced

## Validation

- `npm run typecheck` in `packages/web`: passed after the error-severity
fix and after final implementation
- `npx vitest run src/components/session-sidebar.test.tsx
src/components/session-timeline.test.tsx
src/components/settings/images-settings.test.tsx
src/components/terminal-message-read-observer.test.ts`: 29 passed
- `npm test -w @open-inspect/web`: 114 files, 869 tests passed
- `npm run lint` in `packages/web`: passed
- `npx prettier --check packages/web`: passed
- `npx -y react-doctor@0.9.3 . --json --json-out
/tmp/react-doctor-after.json --yes --blocking none`: complete, selected
tasks gone
- `npx -y react-doctor@0.9.3 . --verbose --scope changed --base
origin/main --yes --blocking none`: no issues found
- `npm run build` in `packages/web`: compiled and typechecked, then hit
the pre-existing `/automations/new` prerender failure described below

## Pre-existing Failures

- The initial full test baseline timed out once in
`src/lib/site-config.test.ts:30`; the final full suite passed all 869
tests.
- The production build fails while prerendering `/automations/new` with
`TypeError: Cannot read properties of null (reading 'useContext')` and
emits generated-page missing-key warnings. The exact `origin/main`
commit reproduces the same failure and warnings in an isolated worktree,
so this PR adds no build regression.

## Deferred

- `effect-needs-cleanup` in `use-session-transport.ts` is a canonical
false positive: the mount effect closes the WebSocket, invalidates
in-flight opens, and clears reconnect timers. No suppression was added.
- The remaining two `only-export-components` findings in shared
button/toggle variant modules were left for a later batch to keep this
PR focused.
- Findings requiring stable product IDs, rendered accessibility
evidence, timezone/locale policy, image-host behavior, performance
measurement, state architecture changes, security decisions, or broad
component decomposition were deferred for code-owner judgment.
- Migration-scale families such as state/effect rewrites, giant
components, list keys, labels, image optimization, and iteration
micro-optimizations remain explicitly out of scope.

## Visual Verification

Not run because this change does not alter UI markup, styling, or
intended visual behavior. The terminal persistence change preserves the
existing visible state while making hydration deterministic.

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

Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
<!-- react-doctor-owner: open-inspect-nightly -->
<!-- react-doctor-bucket: web-nightly-2026-08-04 -->
<!-- react-doctor-base-sha: 23754a2 -->
<!-- react-doctor-diagnostics: 10 -->

## Summary

Fixes **10 root-cause task units** from the nightly React Doctor scan in
`packages/web`.

1. **High** - `react-doctor/no-hydration-branch-on-browser-global` in
`src/app/(app)/session/[id]/page.tsx`: terminal visibility read
`localStorage` during render, so the server and hydrating client could
choose different UI. The terminal preference now uses
`useSyncExternalStore` with a stable server snapshot, preventing
hydration errors while preserving same-tab and cross-tab updates.
2. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/automations/automation-form.tsx` (create-mode Trigger
Type): a `<label>` acted as a section heading without naming one
control. It now uses neutral heading text, avoiding misleading form
semantics for assistive technology.
3. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/automations/automation-form.tsx` (read-only Trigger
Type): a `<label>` described display-only content. It now uses neutral
text so screen readers do not announce an unattached form label.
4. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/automations/automation-form.tsx` (Repository
Configuration): a group heading was represented as an unattached label.
Neutral markup preserves appearance while removing invalid form
semantics.
5. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/automations/automation-form.tsx` (Conditions): a group
heading was represented as an unattached label. Neutral markup avoids an
inaccessible label/control relationship.
6. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/settings/sandbox-settings.tsx` (Service Ports): a
multi-field section heading was an unattached label. Neutral markup
keeps individual port labels authoritative.
7. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/settings/sandbox-settings.tsx` (Tunnel Ports): a
dynamic-list heading was an unattached label. Neutral markup prevents
misleading form semantics.
8. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/settings/sandbox-settings.tsx` (Child Sessions): a
two-field group heading was an unattached label. Neutral markup leaves
each input's associated label intact.
9. **Medium** - `react-doctor/label-has-associated-control` in
`src/components/settings/sandbox-settings.tsx` (Resources): a two-field
group heading was an unattached label. Neutral markup leaves the CPU and
memory labels intact.
10. **Medium** - `react-doctor/no-array-index-as-key` in
`src/components/tool-call-group.tsx`: expanded tool calls used their
array index in React keys, which could transfer row state when events
changed order. The existing stable `toolCallKey(event)` now identifies
each row.

All selected diagnostics were ungrouped (`fixGroupId: null`), so each
diagnostic counted as one task unit. No non-null `fixGroupId` was split
or included.

## Scan Results

React Doctor v0.9.4, schema v3, full `packages/web` scope:

- Before: **99** diagnostics, **2 errors / 97 warnings**, score 59
- After: **89** diagnostics, **1 error / 88 warnings**, score 64
- Raw diagnostics cleared: **10 net**
- `no-hydration-branch-on-browser-global`: 1 -> 0
- `label-has-associated-control`: 13 -> 5
- `no-array-index-as-key`: 8 -> 7
- No new semantic finding was introduced. The same pre-existing
`no-giant-component` finding on `SessionPageContent` received a new
line-based diagnostic ID after lines were added.

The changed-scope regression scan reports only the pre-existing
`prefer-useReducer` warning in `sandbox-settings.tsx`, already present
in the full baseline.

## Validation

- `npm run typecheck` after the error-severity fix: passed
- `npm run typecheck`: passed
- `npx prettier --check packages/web`: passed
- `npm run lint`: passed
- `npm test -- src/components/automations/automation-form.test.tsx
src/components/settings/sandbox-settings.test.tsx`: passed, 71 tests
- `npm test`: baseline and final runs retain intermittent pre-existing
5-second timeout failures. Baseline: 3 timeouts across the two
auth-boundary ESLint tests and Slack routing-limit test. Last final run:
only the Slack routing-limit timeout remained; 875/876 tests passed.
- `npm run build`: reproduces the baseline `/automations/new` prerender
failure, `TypeError: Cannot read properties of null (reading
'useContext')`; compilation and TypeScript complete first.
- `npx -y react-doctor@latest . --json --json-out
/tmp/react-doctor-after.json --yes --blocking none`: valid complete
scan; all selected tasks absent
- `npx -y react-doctor@latest . --verbose --scope changed --base
origin/main --yes --blocking none`: no introduced diagnostic; one
pre-existing touched-file warning

No visual verification was needed: the semantic markup changes retain
identical classes and appearance, and the terminal change preserves
existing UI states while correcting hydration and subscription behavior.

## Deferred

- `effect-needs-cleanup` in `use-session-transport.ts` is a detector
false positive: the socket is created by a callback invoked from a mount
effect, whose existing cleanup invalidates in-flight connection work,
clears the reconnect timer, and closes the exact socket. No suppression
was added.
- Migration-scale component splitting, reducer/state-effect rewrites,
image migration, locale/timezone decisions, and dynamic-import work
remain for later review.
- Custom-control labeling findings that need component API or grouping
decisions remain deferred.
- The iframe sandbox finding remains deferred because changing iframe
permissions requires a product/security review.
- Remaining index-key findings without a proven stable identity and
performance findings without runtime measurements remain deferred.


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

---------

Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
## Summary
- Add coverage for create-time image-build callback URL validation so
mixed control-plane and external callback URLs are rejected before
provider sandbox creation.
- Add coverage for independent clamping of
`build_execution_timeout_seconds` from the outer build sandbox timeout.

## Why
Recent Modal image-build lifecycle changes route callbacks and timeout
budgets through `api_create_build_sandbox`, which is core business logic
for provider-session safety. These tests lock down important validation
behavior without changing production code.

## Testing
- `uv run pytest tests/ -q` from `packages/modal-infra`

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

Co-authored-by: waclaude <colemurray.cs+ghwaclaude@gmail.com>
…ColeMurray#1291)

## Summary

An agent posting through the notify route has its message cut at 2900
characters, mid-sentence. The response reports `truncated: true`, so the
agent finds out — after the fact, with the text already gone and no way
to recover it. The cut lands on the tail, which for any analysis writeup
is where the conclusions and recommendations live.

Observed in practice: an agent posted a long investigation into a Slack
thread, and the message ended `1. No p…`. Everything after that — the
entire "what to do about it" section, the only part the reader needed —
was discarded. The agent then had to guess what had been lost and post a
follow-up.

**The completion path already solved this.** `splitIntoSlackSections`
spreads long text across consecutive section blocks and keeps code
fences balanced across the seam, so a long answer arrives whole. The
notify route never got the same treatment — the same worker had two
different answers to the same Slack limit.

This moves the splitter into `@open-inspect/shared/slack` and uses it in
both places:

- `slack-notify` sanitizes against `RAW_TEXT_INPUT_MAX_LENGTH` (the cap
it already validates and enforces), splits the result, and emits one
section block per chunk. `SLACK_TEXT_MAX_LENGTH` is gone.
- The splitter's ceiling (`MAX_RESPONSE_SECTIONS` ×
`SECTION_TEXT_MAX_CHARS` = 60k) sits far above that 12k input cap, so
`truncated` in the response now reflects only the documented raw-input
limit rather than a Slack rendering detail.
- The completion path is unchanged: it imports the same function from
shared instead of defining it locally.

## Notes

The splitter's tests deliberately stay in
`packages/slack-bot/src/completion/blocks.test.ts`. They exercise it
*through* `buildCompletionBlocks`, so leaving them in place also asserts
that moving the function left the completion output byte-identical.
Moving them to a shared-level suite would have lost that guarantee for
no gain.

The fallback text passed to `chat.postMessage` is now the opening
section rather than the whole body — a section-split message has no
single text body, and Slack only uses that field for the notification
preview.

Worth a separate look: the `slack-notify` tool description doesn't
mention any length behaviour, so an agent has no way to budget for it.
Now that long messages split cleanly that matters less, but stating it
would let the model structure long output deliberately rather than
discovering the limit from a response flag.

## Test plan

- [x] `npm test -w @open-inspect/shared` (545), `-w
@open-inspect/slack-bot` (406), `-w @open-inspect/control-plane` (2312)
- [x] `npm run typecheck`, `npm run lint` clean
- [x] New regression test in `src/routes/slack-notify.test.ts`: a
message well over one section keeps every section within 3000 chars,
emits more than one section, reports `truncated: false`, and — the point
of the test — still contains its closing recommendation. It fails on
`main`, where that tail is cut.
- [x] The existing 406 slack-bot tests pass unchanged, confirming the
completion path is unaffected by the move.

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

* **Bug Fixes**
* Slack notifications now split long responses into multiple sections
without losing the final recommendation.
  * Each section respects Slack’s 3,000-character limit.
* Responses preserve paragraph, line, Unicode, and code-block formatting
where possible.
* Extremely long responses are capped with a truncation indicator after
20 sections.

* **Tests**
* Added regression coverage for oversized Slack messages, section
limits, and truncation behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- include the existing valid callback URL context in the build execution
timeout clamping test
- keep the fix scoped to the stale test fixture; production behavior is
unchanged

## Root cause

PR ColeMurray#1255 was validated on August 3 before callback URLs became required
for build sandbox creation on August 4. It was merged on August 6
without current-base validation, so the newly added timeout test reached
main with an outdated request fixture. The test failed during
callback_url validation before exercising either timeout assertion.

Fixes the CI failure in
https://github.com/ColeMurray/background-agents/actions/runs/31133401945/job/92727432767.

## Validation

- uv run pytest tests/ -q (177 passed)
- uv run ruff check tests/test_web_api_build_sandbox.py
- uv run ruff format --check tests/test_web_api_build_sandbox.py
- git diff --check

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

## Summary by CodeRabbit

* **Tests**
* Updated build-execution-timeout coverage to include standard callback
URL fields in the request payload.

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

- let Slack derive accessible fallback text from every notification
block
- preserve the full content of section-split agent notifications for
screen readers
- align agent notify delivery with the existing completion-delivery
accessibility pattern

## Root cause

PR ColeMurray#1291 split long agent notifications into multiple blocks but
supplied only the opening section as top-level `text`. Slack screen
readers prefer that field over interior blocks, so later sections
remained visually available while being omitted from the accessible
representation.

This uses the existing `postBlocks` client so Slack derives fallback
text from the supported blocks instead.

## Test plan

- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/control-plane --
src/routes/slack-notify.test.ts`
- `npm test -w @open-inspect/control-plane` (2,313 tests)
- `npm run typecheck -w @open-inspect/control-plane`
- `npm run lint -w @open-inspect/control-plane`
- `npm exec prettier -- --check
packages/control-plane/src/routes/slack-notify.ts
packages/control-plane/src/routes/slack-notify.test.ts`


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved Slack notifications for long messages by preserving all
content without truncation.
* Enhanced accessibility by allowing screen readers to derive
notification text from message sections.
  * Maintained message sanitization and threaded notification behavior.

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

Removes the transitional `build_timeout_seconds` →
`provider_session_timeout_seconds` rename-skew shim introduced in ColeMurray#1288,
now that the rename is fully deployed on both planes:

- **control plane** (`sandbox/client.ts`): stop dual-writing the legacy
`build_timeout_seconds` key alongside `provider_session_timeout_seconds`
in the create-build-sandbox payload.
- **Modal** (`web_api.py`): drop the
`legacy_field="build_timeout_seconds"` dual-read alias and remove the
now-unused `legacy_field` parameter from `_validated_timeout_seconds`.
- Tests: the two rename-skew tests are replaced by a single test pinning
that the retired key is now **ignored** (falls back to the default
provider-session timeout), so an accidental revival of the old key would
be caught.

Both shim sites carried "drop once both planes are past the rename"
comments; this is that follow-up. The rename (ColeMurray#1288) has merged, synced
to the production fork, and deployed on both pipelines, so the
currently-deployed control plane already sends the new key and the
currently-deployed Modal prefers it — removal is safe in either
deploy-skew direction.

## Also fixes a red test on main

`test_create_clamps_build_execution_timeout_independently` (added in
ColeMurray#1255) was failing on `main`: its payload predates ColeMurray#1283 making callback
URLs required at create, so the endpoint 400s before reaching the
timeout logic. Since this PR rewrites that test's payload anyway (it
used the retired key), it also adds the required `**CALLBACK_CONTEXT`.

## Testing

- `npm run typecheck -w @open-inspect/control-plane`
- `npm test -w @open-inspect/control-plane` — 2313 passed
- `npm run test:integration -w @open-inspect/control-plane` — 744 passed
- `uv run --frozen pytest tests/` in `packages/modal-infra` — 176 passed
(175 + the repaired one; 1 was failing on main before this PR)
- `ruff check` / `ruff format --check` clean


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

## Summary by CodeRabbit

* **Breaking Changes**
* Removed support for the legacy `build_timeout_seconds` field when
creating image-build sandboxes.
* Use `provider_session_timeout_seconds` to configure the provider
session timeout.
* **Bug Fixes**
* Timeout validation, defaults, and limits now consistently use the
supported timeout field.
* **Tests**
* Updated coverage to verify legacy fields are ignored and timeout
settings remain independently supported.

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

- derive `ArtifactType` and `SessionArtifact` from their canonical Zod
schemas
- remove the generic exported `recordSchema` helper from the artifact
module
- keep identical record validation local to the sandbox-event and
media-request owners

## Scope

This is an ownership-only refactor. It preserves the package-root API
and serialized behavior, and does not add a package path, migrate
consumers, add a facade, add tests, or add import enforcement.

## Architecture

The artifact module retains artifact-specific schemas, including the
already-supported `sessionArtifactSchema`. Generic record validation no
longer creates a runtime dependency from sandbox events to artifacts.
After this change, the artifact module has no unrelated exported helper
blocking later direct-path adoption.

## Validation

- `npm run build -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/shared`
- `npm test -w @open-inspect/shared` (39 files, 553 tests)
- `npm test -w @open-inspect/shared -- src/module-boundaries.test.ts` (4
tests)
- `npm run lint -w @open-inspect/shared`
- `npm run format:check`
- `npm run typecheck` (all workspaces)
- root export and package export files unchanged
- `git diff --check`

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

## Summary by CodeRabbit

* **Refactor**
  * Improved internal validation of session artifacts and metadata.
* Standardized artifact type definitions based on their validation
schemas.
* Preserved existing artifact validation behavior, including optional
update timestamps.

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

- move `spawnContextSchema` and `SpawnContext` out of
`@open-inspect/shared` into the control-plane Session owner
- migrate the Durable Object producer, route validator, and integration
type reference to the internal module
- relocate the existing schema tests with their owner

## Scope

This is an ownership-only refactor. It preserves the schema fields and
serialized behavior, removes the historical root exports that had no
cross-package consumers, and does not add a package path, facade, new
behavior, or import enforcement.

## Architecture

The parent Durable Object spawn-context response contains encrypted SCM
fields and is produced and consumed exclusively inside control-plane.
`src/session/spawn-context.ts` now directly owns that internal response
schema and inferred type. The broader shared Session HTTP API no longer
exposes or depends on it.

## Validation

- `npm run build -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/shared`
- `npm test -w @open-inspect/shared` (39 files, 547 tests)
- `npm run lint -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/control-plane`
- `npm test -w @open-inspect/control-plane` (155 files, 2,319 tests)
- `npm run test:integration -w @open-inspect/control-plane --
do-internal-routes.test.ts` (17 tests)
- `npm run lint -w @open-inspect/control-plane`
- `npm test -w @open-inspect/shared -- src/module-boundaries.test.ts` (4
tests)
- `npm run format:check`
- `npm run typecheck` (all workspaces)
- `git diff --check`
…ray#1292)

## Summary

A Slack mention whose repo-catalog fetch was slow **disappeared**. The
`Working on *X*...` ack posted, and then nothing: no session, no error,
no reply. The user re-mentioned the bot 15 minutes later and it worked,
so it read as flakiness rather than a bug.

The mention handler runs inside `waitUntil`, and `getAvailableRepos`
called `GET /repos` with no bound. On a cold control-plane cache that
request can take tens of seconds, at which point it has consumed the
whole background-task budget and the platform cancels the remainder of
the handler. The cancellation lands *after* the ack and *before* session
creation, which is the worst possible window:

- no session exists, so nothing can complete;
- the ack is already posted, so the thread shows work in progress
forever;
- `startSessionAndSendPrompt`'s own failure branches — which do post
"Sorry, I couldn't create a session" — never run.

Production trace:

```
12:07:08.123  app_mention received
12:07:32.950  control_plane.fetch_repos  outcome=success  repo_count=318  duration_ms=24390
12:07:37.315  (ack posted)
12:07:38.108  WARN waitUntil() tasks did not complete within the allowed time
              after invocation end and have been cancelled
```

The retry against a warm cache: `duration_ms=592`.

## Fix

Bound the fetch at `REPOS_FETCH_TIMEOUT_MS` (5s). `getAvailableRepos`
already falls back to the bot's own KV copy of the catalog on any error,
so a timeout costs a possibly-stale catalog instead of the entire
request. A warm fetch is well under a second, so the bound only trips
when something is genuinely wrong.

`controlPlaneFetch` gains an optional `timeoutMs`; callers that don't
pass one are unchanged.

## Why a bound rather than making the fetch fast

This pairs with ColeMurray#1273. That change made the control plane's cache
refresh survive the *caller* disconnecting, which is exactly what now
happens: the bot gives up at 5s, the refresh completes in the
background, and the next mention is served from a warm cache. Before
both changes an aborted miss left KV empty, so cold could persist
indefinitely.

## Known gap, deliberately not in this PR

The ack is still posted before the session exists, so any *other* way
the handler dies leaves the same dangling "Working on..." message. Two
ways to close that — post the ack only after session creation, or update
it on failure — but the first trades away ~1s of feedback on the happy
path and the second can't help when the isolate is killed outright.
That's a UX call worth making separately from this root-cause fix; happy
to follow up whichever way you prefer.

## Test plan

- [x] `npm test -w @open-inspect/slack-bot` (407), `-w
@open-inspect/shared` (545), `-w @open-inspect/control-plane` (2311)
- [x] `npm run typecheck`, `npm run lint` clean
- [x] New test in `src/classifier/repos.test.ts`: a fetch that rejects
with `TimeoutError` returns the KV-cached catalog, and the request is
asserted to carry an `AbortSignal` created with
`REPOS_FETCH_TIMEOUT_MS`. It asserts the bound via a spy rather than
waiting on real time, so it runs in single-digit milliseconds.


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

* **Bug Fixes**
* Repository catalog requests now time out after five seconds,
preventing indefinitely stalled requests.
* Existing cached repository data continues to be used when requests
time out or fail.

* **Tests**
  * Added coverage confirming timeout behavior and cached-data fallback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

Narrow hygiene pass over two control-plane background-work sites:

1. `durable-object.ts` no longer leaves `processMessageQueue()` as a
floating promise when a sandbox WebSocket connects. The promise is
registered with the existing Durable Object background-work pattern and
unexpected failures are logged as
`message_queue.process.background_error`.
2. `sandbox-events.ts` now attaches structured error handling to the
post-completion snapshot task, logging unexpected failures as
`snapshot.trigger.background_error` with the snapshot reason.

## Scope corrections

- Child-spawn parent notification remains on the Worker
`ExecutionContext.waitUntil()` path. It is a best-effort UI refresh,
does not belong on the successful spawn response path, and later child
status transitions also notify the parent.
- `CallbackNotificationService.notifyComplete()` retains its existing
contract: expected delivery failures are returned and logged, while
unexpected internal failures are logged and rethrown. Promises passed to
`waitUntil()` are observed by the runtime, so a service-wide swallow
policy is unnecessary.
- Durable Object `waitUntil()` is not described as extending object
lifetime; Durable Objects remain active while they have ongoing work or
pending I/O. The added catches provide explicit structured diagnostics.

## Behavior

- No successful-path behavior changes.
- Unexpected queue-processing and snapshot-task failures now produce
structured logs.

## Tests

- Added coverage that a rejected post-completion snapshot task is
handled and logged.
- The sandbox-connect flow remains covered by the control-plane
integration suite; there is no direct SessionDO unit harness for the
one-line queue-processing call site.

## Verification

- Control-plane unit: **155 files, 2320 tests passed**
- Control-plane integration (workerd + D1): **63 files, 744 tests
passed**
- Control-plane typecheck (source + tests): passed
- Full repository lint and format check: passed
## Summary

- expose the existing sandbox-event owner at
`@open-inspect/shared/types/sandbox-events`
- migrate control-plane, web, Slack, and Linear imports and reexports to
that path
- remove the sandbox-event symbols from both the shared root barrel and
the control-plane type aggregator once all repository consumers have
moved

## Scope

This is a mechanical owner-path adoption. It does not change source
declarations, schemas, serialized behavior, or runtime logic, and it
does not add a facade, enforcement rule, or import-only tests.

The repository now has zero imports or reexports of sandbox-event
symbols from `@open-inspect/shared`. Control-plane consumers also import
the sandbox-event owner directly instead of routing through
`src/types.ts`. Shared-package tests use the existing relative owner
path.

## Validation

- `npm run typecheck`
- `npm run format:check`
- `npm test -w @open-inspect/shared` (547 tests)
- `npm test -w @open-inspect/control-plane` (2,319 tests)
- `npm test -w @open-inspect/web` (894 tests)
- `npm test -w @open-inspect/slack-bot` (407 tests)
- `npm test -w @open-inspect/linear-bot` (211 tests)
- affected package production builds
- `git diff --check`
## Summary

- add a user-defined pull/merge-request label to provider-neutral SCM
settings, including global defaults and repository overrides
- resolve the effective SCM policy once for the canonical target
repository before creating a pull request
- ensure missing GitHub labels with read-before-create behavior, confirm
`422` races instead of treating every validation failure as success, and
preserve GitLab label mapping
- add settings, resolution, multi-repository, GitHub, GitLab, and UI
coverage

## Context

This supersedes ColeMurray#867 because the original contributor fork cannot be
updated. Thank you to @kadams54 for the original feature and UX
direction.

The replacement keeps session pull-request policy in the existing
provider-neutral SCM settings model rather than GitHub Bot settings, and
applies repository policy to the actual selected PR target in
multi-repository sessions.

## Validation

- `npm run lint`
- `npm run typecheck`
- `npm test -w @open-inspect/control-plane` (155 files, 2,328 tests)
- `npm test -w @open-inspect/web` (115 files, 897 tests)
- `npm run build -w @open-inspect/control-plane`
- `npm run build -w @open-inspect/web`

## Review

A thermo-nuclear maintainability review found one low-severity
test-isolation issue in the new GitHub provider test. The warning spy is
now restored in `finally`; the review found no other maintainability or
scope issues.


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

* **New Features**
* Added configurable labels for session-created pull and merge requests.
* Labels can be set globally or customized per repository, with blank
values inheriting global defaults.
* Repository draft-mode settings can now be managed independently from
labels and global defaults.
  * Pull request creation supports labels across GitHub and GitLab.
* GitHub creates missing labels when possible without blocking pull
request creation.

* **Bug Fixes**
* Improved handling of whitespace, invalid values, and label creation
conflicts.
* SCM settings failures now return a clear service-unavailable response.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
)

## Summary

- calculate hard-slice fence repairs from the text retained in each
Slack section rather than the final state of the full oversized token
- guarantee repaired fenced sections remain within Slack's
3,000-character API limit
- add a regression test for a long single-line fenced token that
previously produced section lengths of 3,004 and 1,042 characters

## Root cause

When one oversized token both opened and closed a code fence, the
chunker inspected the fence state after the entire token and reserved no
closing repair. The first hard slice could still end inside the fence,
so balancing it added four characters after taking the full
3,000-character budget.

## Verification

- `npm test -w @open-inspect/slack-bot` (408 tests)
- `npm test -w @open-inspect/control-plane --
src/routes/slack-notify.test.ts` (21 tests)
- `npm run lint -w @open-inspect/shared`
- `npm run lint -w @open-inspect/slack-bot`
- `npm run typecheck -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/slack-bot`
- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/slack-bot -- src/completion/blocks.test.ts`
(27 tests, rerun after commit hooks)

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

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

* **Bug Fixes**
* Improved handling of long Slack messages to keep sections within the
3,000-character limit.
  * Preserved balanced code blocks when splitting oversized content.
* Improved Unicode text handling to prevent characters from being split
incorrectly.
* Improved preservation of whitespace, truncation markers, and
code-block language information.

* **Tests**
  * Added coverage for oversized tokens containing code fences.
* Added comprehensive coverage for section limits, fence balancing,
truncation, and edge cases.
<!-- 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>
…tion (ColeMurray#1277)

Fixes ColeMurray#1276.

## Problem

Replying `owner/repo` to the bot's own repository clarification
re-triggers the identical clarification forever — the documented flow
("That answer is used on the next resolution attempt",
`docs/integrations/LINEAR.md`) never worked on the no-session path.

## Changes

- **`webhook-handler.ts`** — a `prompted` event that reaches
`handleNewSession` (i.e. no issue→session mapping exists) is a
clarification reply; its text lives on `agentActivity.content.body`, so
it now takes precedence over the session's original trigger comment when
building the resolution context.
- **`target-resolution.ts`** — new rung in the resolution ladder, ahead
of Linear's suggestions API and LLM classification: `matchExplicitRepo`
resolves deterministically when the comment names exactly one available
repository. Case-insensitive (repos are stored lowercase),
boundary-guarded (`acme/api` does not match inside `acme/api-legacy` or
`notacme/api`), and multiple distinct matches still fall through to
classification, since that is a genuine ambiguity.

## Testing

- Unit tests for `matchExplicitRepo` (single/multi/none/boundary/case).
- End-to-end handler test: `prompted` event with no session mapping +
`acme/backend` reply → session created against `acme/backend`; the
control-plane stub throws on any unexpected fetch, so the LLM classifier
being consulted would fail the test.
- `npm test -w @open-inspect/linear-bot`: 208/208. Typecheck + eslint
clean.

Observed in production: an issue describing UI work didn't match either
repo description, and three consecutive exact `owner/repo` replies each
re-ran cold classification with identical reasoning.

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

* **New Features**
* Explicit repository names in comments are now recognized with
case-insensitive matching.
* Repository resolution prioritizes clear repository references before
heuristic classification.
* Repository matching preserves the repository’s full name when
resolved.

* **Bug Fixes**
* Improved clarification replies so explicitly specified repositories
resolve correctly.
* Prevented ambiguous, partial, or incorrectly delimited repository-name
matches.
* Ensured prompted activity is attributed to the responding actor when
creating a session.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- clarify that the existing GitHub App `Pull requests: Read & write`
permission authorizes creating and applying labels to session-created
pull requests
- keep `Issues: Read & write` conditional on enabling the GitHub bot
- align the canonical setup guide, onboarding workflow, web setup
README, and GitHub bot permission guidance

## Context

Commit `b63d017` added control-plane support for ensuring and applying
labels to session-created GitHub pull requests. Although these
operations use label endpoints under Issues API routes, GitHub App
permission mapping authorizes them through `Pull requests: Read &
write`; installations do not need an Issues permission upgrade solely
for session PR labeling.

Terraform configures GitHub App credentials but does not create or
update the App itself, and there is no checked-in GitHub App permission
manifest to change.

## Verification

- `npx prettier --check "docs/GETTING_STARTED.md"
"packages/web/README.md" ".claude/skills/onboarding/SKILL.md"
"packages/github-bot/README.md"`
- `git diff --check`
- targeted searches confirmed no documentation still claims Issues
permission is required for session PR labeling

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

## Summary by CodeRabbit

- **Documentation**
- Clarified GitHub App permission requirements for contents, pull
requests, issues, and metadata.
- Documented that pull request write access supports labeling
session-created pull requests.
- Specified that Issues read/write access is required for labeling and
GitHub bot functionality when enabled.
- Added guidance for updating existing installations, including
republishing settings and approving permission changes.

<!-- 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>
Follow-up to ColeMurray#1277.

## Problem

A second pass over the merged clarification-routing change found two
contract edge cases:

- Linear sends promptContext at the top level of an AgentSessionEvent
payload, but the bot typed and read it from agentSession. Created
sessions therefore discarded Linear's complete issue, thread,
parent-issue, and guidance context and rebuilt a smaller fallback
prompt.
- Explicit repository matching rejected single-period continuations such
as acme/backend.docs, but repeated periods such as acme/backend..docs
could still produce a false acme/backend match.

## Changes

- Model and consume promptContext at the webhook payload's top level.
- Add a handler integration test that verifies the top-level context
reaches the control-plane session prompt.
- Scan the entire adjacent period run when deciding whether owner/repo
is embedded in a longer path, while retaining ordinary terminal
ellipses.
- Verify that an unmapped prompted clarification reply retains its full
instruction text in the newly created session prompt.
- Document the actual five-stage repository-resolution cascade.

## Linear documentation

- [Developing the Agent
Interaction](https://linear.app/developers/agent-interaction) documents
promptContext as the formatted context for created events and the
separate prompted-event flow.
- [Pinned AgentSessionEventWebhookPayload
schema](https://github.com/linear/linear/blob/eabc85d0df87617b4647e56d2f236e60bc2ed117/packages/sdk/src/schema.graphql#L964-L1006)
shows promptContext on the top-level event payload and marks it as
present only for created events.

## Testing

- npm run build -w @open-inspect/shared
- npm test -w @open-inspect/linear-bot — 221 passed
- npm run typecheck -w @open-inspect/linear-bot
- npm run lint -w @open-inspect/linear-bot
- npm run build -w @open-inspect/linear-bot
- Prettier check and git diff --check

An independent thermo-review found no actionable maintainability
findings.


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

## Summary by CodeRabbit

- **Improvements**
- Repository detection now more accurately handles punctuation and
path-like text, reducing incorrect matches.
- Repository resolution documentation now explains the full five-step
matching process.
- Prompt context is consistently accepted and forwarded from webhook
events, including clarification instructions.

- **Bug Fixes**
- Corrected handling of top-level prompt context for newly created
sessions.
- Added coverage for repository names followed by ellipses and rejection
of ambiguous repeated-period matches.

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

Supersedes ColeMurray#1218 because the original source branch is not editable by
the maintainers. The original implementation commit by @duboff is
preserved with its authorship.

- Fetch Slack thread history only after a channel-trigger invocation is
admitted, and reuse one read across matching automations.
- Keep Slack API access, display-name resolution, and thread rendering
in `slack-bot`; the signed internal endpoint still returns `{
threadContext: string }`.
- Select a bounded 20-message window with the thread root pinned,
exclude the triggering and later replies, and allow text-bearing
`file_share` messages to trigger.
- Serialize untrusted thread messages as JSON with discriminated speaker
identities so display names cannot impersonate the assistant or an app.

## Takeover fixes

- Resolved the `packages/shared/src/slack/index.ts` conflict against
current `main`, retaining both the current section exports and the
thread-context exports.
- Preserved speaker kind, Slack ID, and user display name as separate
fields.
- Made the triggering-message permalink an explicit typed Slack event
field instead of a hidden dependency on the logging metadata bag.
- Contained lazy prompt-provider failures inside `startInvocation`,
falling back to the baseline prompt so admitted children cannot remain
stranded in `starting`.
- Enforced empty results for non-positive thread-window limits.
- Avoided display-name lookups for app messages in the interactive
thread path.
- Hardened fetch-call and prompt-ordering assertions against false
negatives.
- Added direct `sinceTs`, non-positive-limit, typed-permalink,
lazy-provider-failure, and adversarial display-name regressions.
- Corrected docs to state that the root is included within the
20-message total.

## Design notes

- The takeover does not introduce a shared thread renderer or a new
typed RPC/auth framework. Slack continues to own thread rendering, and
the endpoint preserves its existing string response.
- Slack `conversations.replies` returns the earliest messages first and
offers no reverse/tail mode. Preserving both the root and latest replies
therefore requires pagination; the read is admission-bound, capped at 25
pages, and protected by the control-plane 10-second fallback timeout.
- The configured bot token is the intended channel-history credential;
repository setup already requires `channels:history` and
`groups:history`.

## Validation

- `npm test` (4,578 tests)
- `npm run typecheck`
- `npm run lint`
- `npm run lint:complexity`
- `npm run format:check`
- `git diff --check origin/main...HEAD`

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

## Summary by CodeRabbit

- **New Features**
- Slack automations can trigger on text-containing file-share messages;
attachments are not forwarded, and image-only messages remain excluded.
- Threaded runs can include up to 20 earlier messages with speaker
labels, bounded content, and safe formatting.
- Thread context loads only for admitted threaded runs and is shared
across matching automations.
  - Conditions continue to evaluate the triggering message text only.

- **Bug Fixes**
- Slack history failures, timeouts, or unavailable data no longer
prevent automation runs.

- **Documentation**
- Updated Slack automation guidance covering file-share handling and
thread context behavior.

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

---------

Co-authored-by: Mikhail Dubov <mikhail@chattermill.io>
## Summary

- serve the canonical, secret-free session representation from `GET
/sessions/:id` for server rendering
- send the same `SessionSnapshot` shape on every WebSocket subscribe or
reconnect, then continue with the existing semantic live messages
- keep stable `eventId` and `timelineSequence` envelopes for
deterministic replay and history pagination
- fetch code-server and terminal credentials through the separate
authenticated, no-store `/sessions/:id/sandbox-access` endpoint
- keep rendered session content visible while reconnecting and gate live
actions until the socket snapshot is installed

The canonical representation is `{ session, artifacts, timeline,
spawnError }`. It has no duplicate session ID, bootstrap lifecycle
terminology, or parallel public state endpoint. Sandbox access is
explicitly limited to interactive sandbox services; integration and SCM
credentials retain their own domain-specific flows.

## Simplified architecture

1. The protected Next.js session route fetches and validates `GET
/sessions/:id` on the server.
2. A route-local provider gives the existing client page that snapshot
for its initial render.
3. The browser opens the existing session WebSocket.
4. After authentication and async enrichment, SessionDO performs a final
canonical SQLite snapshot read.
5. A synchronous handoff sends `subscribed`, registers the socket, and
persists its identity without an `await` between those operations.
6. A mutation is therefore either included in the snapshot or delivered
afterward as an ordered semantic WebSocket message.
7. Reconnect repeats the same bounded full-snapshot handoff; there is no
retained view-delta log or revision recovery state machine.

## Removed from the previous design

- `session_view_metadata` and `session_view_deltas` storage
- view revisions, per-socket applied revisions, and catch-up
byte/revision limits
- delta/snapshot/ready synchronization messages and reducer recovery
branches
- capability negotiation and legacy dual-protocol fan-out
- the duplicate `/sessions/:id/bootstrap` resource, duplicate
`sessionId`, and identity refinements
- the generic `/sessions/:id/access` name and `session_access_changed`
protocol event
- raw replay duplicates alongside stable timeline envelopes
- credential-bearing snapshots and WebSocket messages
- app-wide auth hydration and a duplicated 700+ line session client
- client-side timeline sorting, copied event envelopes, and redundant
integration scenarios

The simplification passes reduce the review surface from 4,147 changed
lines across 51 files to 1,975 lines across 41 files: 2,172 fewer
changed lines (52%).

## Correctness and security

- HTTP SSR and WebSocket subscribe share one schema and one SessionDO
snapshot builder
- the snapshot read, send, socket registration, and mapping persistence
form one synchronous handoff
- duplicate subscribe attempts are rejected, including sockets already
synchronizing
- malformed server messages enter bounded reconnect instead of leaving
the client connected but unready
- session snapshots never contain passwords or bearer tokens
- sandbox credentials are decrypted only for the authenticated
sandbox-access endpoint, with a post-decryption row recheck to reject
concurrent sandbox replacement
- sandbox credentials are cleared before reconnect/access invalidation
refetches and when sandbox lifecycle state makes them unusable
- stable event envelopes and cursors preserve ordered replay/history
pagination without a client revision state machine

## Verification

- shared, control-plane, and web TypeScript typechecks
- shared tests: 549 passed
- control-plane unit tests: 2,334 passed
- control-plane integration tests: 746 passed
- web tests: 911 passed
- shared and control-plane production builds; optimized Next.js
production build
- shared, control-plane, and web ESLint
- Prettier and `git diff --check`

The client and control-plane protocol changes are intentionally
coordinated; no legacy compatibility path remains.

---------

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

- fetch one lookahead row when building the initial session replay
- report `hasMore` only when that lookahead row exists
- trim the oldest lookahead row so replay still returns at most 500
newest events
- cover the exact 500-event and 501-event boundaries through the real
WebSocket subscription flow

## Root cause

The replay query fetched at most the configured limit and inferred
additional history from `rows.length >= limit`. Exactly 500 events
therefore looked indistinguishable from 501 or more events, causing an
unnecessary empty history request.

## Testing

- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/control-plane` (2,344 passed)
- `npm run test:integration -w @open-inspect/control-plane --
websocket-client.test.ts` (19 passed)
- `npm run typecheck -w @open-inspect/control-plane`
- scoped ESLint and Prettier checks
- `git diff --check`


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

* **Bug Fixes**
  * Improved event replay pagination and cursor handling.
* Replay requests now return up to 500 events and accurately indicate
when more are available.
  * Heartbeat events are excluded from replay results.
  * Improved handling of parsed and malformed replayed event data.
* Added coverage for sessions containing exactly 500 and more than 500
events.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- translate confirmed parent OpenCode `session.compacted` signals into a
provider-neutral `context_compacted` sandbox event
- persist, broadcast, filter, replay, and paginate compaction markers
through the existing session event pipeline
- render a neutral `Context compacted to continue` row in the session
timeline without exposing internal summaries
- document scope, compatibility, rollout guidance, risks, and acceptance
criteria
- add TDD coverage across shared schemas, sandbox runtime, control plane
unit/integration paths, and web live/replay/history rendering

## Scope

This first release intentionally covers successful parent-session
compaction only. Child Task-session compactions, inferred token/context
metrics, summary exposure, and ACK-aware exact delivery remain out of
scope.

## Verification

- `npm run typecheck`
- `npm test -w @open-inspect/shared` (578 tests)
- `npm test -w @open-inspect/control-plane` (2341 tests)
- `npm test -w @open-inspect/web` (902 tests)
- `npm run test:integration -w @open-inspect/control-plane` (746 tests)
- `NODE_ENV=production npm run build -w @open-inspect/web`
- touched-package ESLint for shared, control-plane, and web
- focused sandbox runtime compaction suites (87 tests)
- sandbox runtime Ruff checks and formatting
- mypy for `prompt_stream.py`

The clean-environment full sandbox runtime suite passed 715 of 716
tests; the remaining unrelated signer integration assertion depends on
the installed Git version's SSH signing invocation (`-U`). Root lint
also reports pre-existing generated `.opencode` global errors, while all
touched package lint checks pass.

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

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

* **New Features**
* Added support for displaying “Context compacted” markers in session
timelines.
* Context compaction events are now preserved in session history and
replayed in chronological order.
* Live updates broadcast compaction markers without disrupting ongoing
assistant text or session activity.
* Repeated parent-session compactions are recorded individually, while
child-session compactions remain isolated.

* **Bug Fixes**
* Event validation now consistently accepts supported event types and
filters unrelated events from event history.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<img width="390" height="844" alt="image"
src="https://github.com/user-attachments/assets/8ab52663-b50d-487b-b652-a64b9bc330d5"
/>

---------

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

Bumps the uv group with 1 update in the /packages/e2b-infra directory:
[h2](https://github.com/python-hyper/h2).
Bumps the uv group with 1 update in the /packages/modal-infra directory:
[h2](https://github.com/python-hyper/h2).

Updates `h2` from 4.3.0 to 4.4.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/python-hyper/h2/blob/master/CHANGELOG.rst">h2's
changelog</a>.</em></p>
<blockquote>
<h2>4.4.1 (2026-08-03)</h2>
<p><strong>Bugfixes</strong></p>
<ul>
<li>Performance improvement: remove consumed frames in-place from data
buffer.</li>
<li>Reject duplicate Host headers in request headers. Thanks to Sunand
Mohan for the report.</li>
</ul>
<h2>4.4.0 (2026-07-23)</h2>
<p><strong>API Changes (Backward Incompatible)</strong></p>
<ul>
<li>Support for Python 3.9 has been removed.</li>
<li>Support for PyPy 3.9 has been removed.</li>
<li><code>Stream.end_stream()</code> now raises
<code>NoSuchStreamError</code> or <code>StreamClosedError</code>
exceptions, instead of a generic <code>KeyError</code>.</li>
<li>Duplicate <code>content-length</code> headers with different values
now raise <code>ProtocolError</code>.
Previously, the first <code>content-length</code> header was accepted
and later conflicting values were ignored. Thanks to Harshal Parekh for
the report.</li>
<li>Parse <code>content-length</code> headers according to RFC9110
grammar for numbers (1*DIGIT). Thanks to Arkadiusz Marta for the
report.</li>
<li><strong>backfill from v4.3.0</strong> Convert emitted events into
Python <code>dataclass</code>, which introduces new constructors with
required arguments.
Instantiating these events without arguments, as previously commonly
used API pattern, will no longer work.</li>
</ul>
<p><strong>API Changes (Backward Compatible)</strong></p>
<ul>
<li>Support for Python 3.14 has been added.</li>
<li><code>H2Connection.receive_data</code> now accepts any byte-like
object that
implements the buffer protocol, such as <code>bytes</code>,
<code>bytearray</code>, and
<code>memoryview</code>. Existing <code>bytes</code> callers are
unaffected.</li>
<li>Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC
8441 s4.
Ordinary CONNECT now requires <code>:method=CONNECT</code> and
<code>:authority</code>, and
forbids <code>:scheme</code>/<code>:path</code>. Extended CONNECT (e.g.,
WebSocket) requires
<code>:scheme</code>, <code>:path</code>, <code>:authority</code> plus
<code>:protocol</code>. (PR <a
href="https://redirect.github.com/python-hyper/h2/issues/1309">#1309</a>)</li>
<li>Fix incorrect substring matching of secure header in
<code>cookie</code> and <code>:method</code>.</li>
</ul>
<p><strong>Bugfixes</strong></p>
<ul>
<li>Fix to allow sending 0 bytes on a stream even if the flow control
window is negative.</li>
<li>Reject non-zero <code>SETTINGS_ENABLE_PUSH</code> values received
from servers.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/python-hyper/h2/commit/bc239af1d1b85bc70482804f30a0e0e587d90a08"><code>bc239af</code></a>
v4.4.1</li>
<li><a
href="https://github.com/python-hyper/h2/commit/92b925ed1b1817c82db32893503f74f47fcf4452"><code>92b925e</code></a>
add test for duplicate host headers</li>
<li><a
href="https://github.com/python-hyper/h2/commit/292a40829feefda98c8509dcdbbb4a57af9bd6a6"><code>292a408</code></a>
reject duplicate Host headers in request headers</li>
<li><a
href="https://github.com/python-hyper/h2/commit/04d3b87cbc1db020d28c7cfb44fe194558efbdde"><code>04d3b87</code></a>
update changelog</li>
<li><a
href="https://github.com/python-hyper/h2/commit/439b970d0fa19891fa81068907de97dfa3a07c3a"><code>439b970</code></a>
prepare for next release cycle</li>
<li><a
href="https://github.com/python-hyper/h2/commit/9a7ff7430df669fa8e90b6121f3cc1ed64d1115a"><code>9a7ff74</code></a>
performance: remove consumed frames in place from data buffer (<a
href="https://redirect.github.com/python-hyper/h2/issues/1321">#1321</a>)</li>
<li><a
href="https://github.com/python-hyper/h2/commit/6cce763997eca5b826f3e435a611b7a7fc73f633"><code>6cce763</code></a>
v4.4.0</li>
<li><a
href="https://github.com/python-hyper/h2/commit/dfafda3b0cd96455b45d1785ef1ebc6968bba5cf"><code>dfafda3</code></a>
Bump pytest from 8.4.2 to 9.0.3 (<a
href="https://redirect.github.com/python-hyper/h2/issues/1320">#1320</a>)</li>
<li><a
href="https://github.com/python-hyper/h2/commit/b45207cedf9fabe2c77bb3c1c10f403a610599a0"><code>b45207c</code></a>
dependencies and packaging++</li>
<li><a
href="https://github.com/python-hyper/h2/commit/c40145f69c5473850849fe96301ac0416b1afea6"><code>c40145f</code></a>
parse <code>content-length</code> headers according to RFC9110 grammar
for numbers (1*DI...</li>
<li>Additional commits viewable in <a
href="https://github.com/python-hyper/h2/compare/v4.3.0...v4.4.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `h2` from 4.3.0 to 4.4.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/python-hyper/h2/blob/master/CHANGELOG.rst">h2's
changelog</a>.</em></p>
<blockquote>
<h2>4.4.1 (2026-08-03)</h2>
<p><strong>Bugfixes</strong></p>
<ul>
<li>Performance improvement: remove consumed frames in-place from data
buffer.</li>
<li>Reject duplicate Host headers in request headers. Thanks to Sunand
Mohan for the report.</li>
</ul>
<h2>4.4.0 (2026-07-23)</h2>
<p><strong>API Changes (Backward Incompatible)</strong></p>
<ul>
<li>Support for Python 3.9 has been removed.</li>
<li>Support for PyPy 3.9 has been removed.</li>
<li><code>Stream.end_stream()</code> now raises
<code>NoSuchStreamError</code> or <code>StreamClosedError</code>
exceptions, instead of a generic <code>KeyError</code>.</li>
<li>Duplicate <code>content-length</code> headers with different values
now raise <code>ProtocolError</code>.
Previously, the first <code>content-length</code> header was accepted
and later conflicting values were ignored. Thanks to Harshal Parekh for
the report.</li>
<li>Parse <code>content-length</code> headers according to RFC9110
grammar for numbers (1*DIGIT). Thanks to Arkadiusz Marta for the
report.</li>
<li><strong>backfill from v4.3.0</strong> Convert emitted events into
Python <code>dataclass</code>, which introduces new constructors with
required arguments.
Instantiating these events without arguments, as previously commonly
used API pattern, will no longer work.</li>
</ul>
<p><strong>API Changes (Backward Compatible)</strong></p>
<ul>
<li>Support for Python 3.14 has been added.</li>
<li><code>H2Connection.receive_data</code> now accepts any byte-like
object that
implements the buffer protocol, such as <code>bytes</code>,
<code>bytearray</code>, and
<code>memoryview</code>. Existing <code>bytes</code> callers are
unaffected.</li>
<li>Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC
8441 s4.
Ordinary CONNECT now requires <code>:method=CONNECT</code> and
<code>:authority</code>, and
forbids <code>:scheme</code>/<code>:path</code>. Extended CONNECT (e.g.,
WebSocket) requires
<code>:scheme</code>, <code>:path</code>, <code>:authority</code> plus
<code>:protocol</code>. (PR <a
href="https://redirect.github.com/python-hyper/h2/issues/1309">#1309</a>)</li>
<li>Fix incorrect substring matching of secure header in
<code>cookie</code> and <code>:method</code>.</li>
</ul>
<p><strong>Bugfixes</strong></p>
<ul>
<li>Fix to allow sending 0 bytes on a stream even if the flow control
window is negative.</li>
<li>Reject non-zero <code>SETTINGS_ENABLE_PUSH</code> values received
from servers.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/python-hyper/h2/commit/bc239af1d1b85bc70482804f30a0e0e587d90a08"><code>bc239af</code></a>
v4.4.1</li>
<li><a
href="https://github.com/python-hyper/h2/commit/92b925ed1b1817c82db32893503f74f47fcf4452"><code>92b925e</code></a>
add test for duplicate host headers</li>
<li><a
href="https://github.com/python-hyper/h2/commit/292a40829feefda98c8509dcdbbb4a57af9bd6a6"><code>292a408</code></a>
reject duplicate Host headers in request headers</li>
<li><a
href="https://github.com/python-hyper/h2/commit/04d3b87cbc1db020d28c7cfb44fe194558efbdde"><code>04d3b87</code></a>
update changelog</li>
<li><a
href="https://github.com/python-hyper/h2/commit/439b970d0fa19891fa81068907de97dfa3a07c3a"><code>439b970</code></a>
prepare for next release cycle</li>
<li><a
href="https://github.com/python-hyper/h2/commit/9a7ff7430df669fa8e90b6121f3cc1ed64d1115a"><code>9a7ff74</code></a>
performance: remove consumed frames in place from data buffer (<a
href="https://redirect.github.com/python-hyper/h2/issues/1321">#1321</a>)</li>
<li><a
href="https://github.com/python-hyper/h2/commit/6cce763997eca5b826f3e435a611b7a7fc73f633"><code>6cce763</code></a>
v4.4.0</li>
<li><a
href="https://github.com/python-hyper/h2/commit/dfafda3b0cd96455b45d1785ef1ebc6968bba5cf"><code>dfafda3</code></a>
Bump pytest from 8.4.2 to 9.0.3 (<a
href="https://redirect.github.com/python-hyper/h2/issues/1320">#1320</a>)</li>
<li><a
href="https://github.com/python-hyper/h2/commit/b45207cedf9fabe2c77bb3c1c10f403a610599a0"><code>b45207c</code></a>
dependencies and packaging++</li>
<li><a
href="https://github.com/python-hyper/h2/commit/c40145f69c5473850849fe96301ac0416b1afea6"><code>c40145f</code></a>
parse <code>content-length</code> headers according to RFC9110 grammar
for numbers (1*DI...</li>
<li>Additional commits viewable in <a
href="https://github.com/python-hyper/h2/compare/v4.3.0...v4.4.1">compare
view</a></li>
</ul>
</details>
<br />


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 the sandbox-only `send-child-prompt` tool for queueing follow-up
instructions in direct child sessions
- add a parent-scoped control-plane route with D1 and child Durable
Object lineage checks
- reuse the child session's durable prompt queue, owner attribution,
sandbox restoration, and event pipeline
- support completed/failed child resumption while keeping
cancelled/archived sessions terminal
- add prompt size, queue depth, and best-effort concurrent-child
admission guardrails
- harden cancellation, archival, prompt dispatch, deterministic FIFO
ordering, and late completion handling
- expose unfinished child work so prior results are labeled accurately
- add the implementation plan and HTML architecture presentation

## Security and lifecycle

- validates the parent sandbox token against the parent session path
- restricts follow-ups to direct children and verifies lineage again in
the child Durable Object
- derives author identity from the child owner; callers cannot provide
source, identity, callbacks, attachments, or model overrides
- terminalizes processing and pending prompts with completion events and
callbacks during cancellation
- prevents late sandbox completion events from overwriting synthetic
cancellation results

## Reviews

- completed an independent implementation-to-specification audit and
corrected all actionable deviations
- completed two independent self-review passes and iterated on every
finding
- retained one improvement over the original specification: transport
execution is isolated in a testable helper, and tool naming follows the
newer explicit child-session convention (`send-child-prompt`)

## Verification

- `npm test -w @open-inspect/shared` (528 tests)
- `npm test -w @open-inspect/control-plane` (2,219 tests)
- `npm run test:integration -w @open-inspect/control-plane` (687 tests)
- `npm run typecheck`
- runtime Node tests (15 tests)
- sandbox tool installation tests (25 tests)
- Ruff checks
- Prettier and `git diff --check`

The repository-wide Python suite was also run, but it is not hermetic
inside a live restored Open-Inspect sandbox: 24 unrelated tests inherit
active restore/auth/OAuth environment variables. The changed Python
installation test and all runtime tests relevant to this feature pass.

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

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

* **New Features**
* Added the `send-child-prompt` tool for queueing follow-up instructions
in direct child sessions.
* Added validation, ownership checks, queue limits, and clear error
reporting.
* Child status now indicates when a follow-up prompt is queued or
processing.
  * Improved enforcement of concurrent child-session limits.
* **Bug Fixes**
  * Improved handling of cancelled, archived, and completed sessions.
* Ensured queued work is cancelled and unfinished messages are finalized
consistently.
* **Documentation**
* Documented child-session follow-up prompts, lifecycle behavior,
authentication, and status retrieval.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

- add opt-in browser-based VNC desktops across the sandbox runtime,
providers, control plane, and web session UI
- provision authenticated noVNC access and lifecycle-managed credentials
- preserve the generic v56 spawn compatibility floor while the
background rebuild policy converges prebuilt images to the VNC-capable
v57 runtime

## Review iteration

This supersedes ColeMurray#1293 without modifying the contributor branch. It
retains the original commits and authorship from @leejayhsu, includes
the current `main` conflict resolution, and applies the follow-up
requested during review.

VNC startup remains best-effort. During the rebuild gap, a VNC-enabled
session may select a compatible v56 image: the core sandbox and agent
still boot, while the VNC endpoint remains unavailable until a new
prebuilt image is ready. New sessions use the rebuilt image; a session
created during the gap may retain its older runtime snapshot.

## Validation

- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/control-plane --
src/image-builds/rebuild-policy.test.ts
src/sandbox/lifecycle/image-selection.test.ts
src/sandbox/lifecycle/manager.test.ts` (113 tests)
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/image-builds.test.ts` (50 tests)
- `npm run typecheck -w @open-inspect/control-plane`
- targeted ESLint and Prettier checks for all changed control-plane
files

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

* **New Features**
* Added optional VNC Desktop access for sandbox sessions, including
noVNC links and connection credentials.
* Added VNC enablement controls at global, environment, and repository
levels.
* Added configurable VNC service-port settings with conflict validation.
* VNC access now works across sandbox creation, restoration, and
resumption.
  * Added VNC status and access links to the session sidebar.
* **Security**
* Sensitive VNC passwords are protected and excluded from snapshots and
real-time messages.
* **Bug Fixes**
  * Improved cleanup of VNC access data when sandboxes stop or restart.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Leejay Hsu <37034741+leejayhsu@users.noreply.github.com>
## Summary

- add an August 9 changelog section covering browser-based sandbox
desktops
- document pull request draft and label policies, child-session
follow-ups, and per-user unread outcomes
- highlight Slack thread context and the more resilient session timeline
experience

## Validation

- `npx prettier --check CHANGELOG.md`
- `git diff --check -- CHANGELOG.md`

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

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

- **New Features**
  - Added browser-based sandbox desktops for isolated work.
  - Added follow-up prompts for child sessions.
  - Improved context-aware Slack automations.
  - Made session timelines more resilient.
  - Added pull-request labels and clearer draft policies.
  - Added unread indicators for session outcomes.
- **Documentation**
  - Added changelog entries for August 3, 5, 7, 8, and 9, 2026.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- refetch code-server and VNC access when the sandbox reaches `ready` or
`running`
- add regression coverage for the access-change/readiness event sequence

## Testing
- `npm test -w @open-inspect/web --
src/hooks/use-session-socket.test.tsx
src/hooks/use-sandbox-access.test.tsx`
- `npm run typecheck -w @open-inspect/web`
- `npx eslint src/hooks/use-session-socket.ts
src/hooks/use-session-socket.test.tsx`

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

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

* **Bug Fixes**
* Sandbox access updates are now announced only after the sandbox is
fully ready.
* Improved event ordering so readiness status appears before access
details.
* Prevented premature access notifications during sandbox startup,
restoration, or resume.
* Confirmed sandbox access remains available once the connection is
successfully established.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
ColeMurray and others added 27 commits August 22, 2026 22:41
## Problem

`main` currently has two migrations numbered `0067`:

```
terraform/d1/migrations/0067_default_single_provider_accounts.sql   # ColeMurray#1561
terraform/d1/migrations/0067_keyboard_shortcut_preferences.sql      # ColeMurray#1562
```

`scripts/d1-migrate.sh` validates prefixes up front and **exits
non-zero** on a duplicate:

```
ERROR: duplicate migration version prefixes detected:
  0067
Renumber the colliding files so each prefix is unique before deploying.
```

Because that runs inside `terraform apply`, this is not a contained
failure — the whole apply fails, so the workers and the data plane don't
ship either. The script's own comment anticipates exactly this case
("two PRs that each grab the next number and then both merge").

There's a second, quieter problem.
`migration-0067-default-single-provider-accounts.test.ts` resolves its
migration by numeric prefix:

```ts
const migration = env.TEST_MIGRATIONS.find((entry) => entry.name.startsWith("0067"));
```

With two `0067` files, `find` returns whichever the loader yields first,
so the test can apply `keyboard_shortcut_preferences` and then assert
against `model_provider_account_defaults`.

## Fix

ColeMurray#1562 merged first (2026-08-21 23:41) and keeps `0067`; ColeMurray#1561's
migration (2026-08-22 22:03) moves to `0068`. The two are independent —
one creates `keyboard_shortcut_preferences`, the other backfills
`model_provider_account_defaults` — so relative order doesn't matter.
The test is renamed and repointed at `0068`.

Both files are renames; the SQL content is untouched.

## Verification

The equivalent change is already deployed downstream, where the ledger
applied both cleanly under distinct versions:

| version | name |
|---|---|
| `0068` | `0068_default_single_provider_accounts.sql` |
| `0067` | `0067_keyboard_shortcut_preferences.sql` |

`0068`'s backfill is a safe no-op where a provider already has a default
(its `NOT EXISTS` guard), and the integration test binds to the right
migration and passes.

## Note for anyone already running `main`

If an installation applied these before this fix, its
`_schema_migrations` already records `0067` under one of the two names.
`d1-migrate.sh` errors when a version is recorded under a different
name, so those installations should confirm their ledger matches this
numbering (`0067` = `keyboard_shortcut_preferences`) before the next
deploy.

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

## Summary by CodeRabbit

* **New Features**
* Automatically creates default provider-account records when an active
provider has exactly one eligible account and no default is configured.
  * Preserves account metadata and sets the appropriate unattended mode.

* **Bug Fixes**
* Corrected migration validation to reference migration 0068 and report
the matching missing-migration error.

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

## Summary
- replace separate model and reasoning-effort controls with one
consolidated selector
- use nested model and effort menus on desktop and an in-place
drill-down flow on mobile
- cap long model lists to four visible rows with scrolling
- align desktop submenus with the bottom of the session input while
preserving viewport collision handling
- apply the shared selector to both new-session and session-detail
prompt composers
- remove the old cycling effort control and add focused interaction
coverage

## Verification
- `npm test` in `packages/web` (1,197 tests passed)
- `npm run typecheck` in `packages/web`
- `npm run lint -- --no-warn-ignored` in `packages/web`

## Visual verification
- desktop model menu alignment: artifact
`220af008827218befb8834d284a0e46c`
- desktop effort menu alignment: artifact
`a411eea87eed78dc6063a0f97d5f0f8c`
- mobile drill-in behavior: artifact `18074e4e714c855069dbd1d2cc868c78`

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

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

* **New Features**
* Added a unified **Model and effort** selector for choosing models and
reasoning levels.
  * Added responsive menus with nested navigation on desktop and mobile.
* Added grouped model options and clearly formatted reasoning-effort
choices.
* The selector reflects current selections and supports disabled states.

* **Improvements**
* Replaced separate model and reasoning controls with one consistent
interface across the app.

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

---------

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- display `now` instead of `just now` for timestamps less than one
minute old
- keep provider account relative-date labels grammatically correct
- add regression coverage for the compact timestamp

## Testing
- `npm test -w @open-inspect/web -- src/lib/time.test.ts`
- `npm run lint -w @open-inspect/web`
- `npm run typecheck -w @open-inspect/web`

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Updated relative timestamps from “just now” to the more concise “now”
for events occurring within the past minute.
* Provider account settings now consistently display the updated
relative time label.

* **Tests**
* Added coverage confirming timestamps from 30 seconds ago display as
“now.”

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- move repository and branch selectors above the new-session composer
- reduce provider authentication to a trailing kebab menu after skills
- remove decorative icons from repository, branch, and model selectors
- preserve accessible authentication selection labels and existing menu
behavior

## Testing
- `npm test -w @open-inspect/web -- --run
src/components/model-reasoning-selector.test.tsx
src/components/session-prompt-composer.test.tsx
'src/app/(app)/page.test.tsx'`
- `npm run typecheck -w @open-inspect/web`
- Prettier and `git diff --check`

## Visual verification
- Desktop viewport, 1440x900: artifact
`362591d2fcbb9424a10589b8810821c8`
- Mobile viewport with collapsed sidebar, 390x844: artifact
`0a25ce8dec63a49935c0b17d16ed5da8`

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

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

## Summary by CodeRabbit

- **UI Improvements**
- Moved repository and branch selectors above the prompt composer for
easier access.
- Grouped skill, model, and provider options into the session controls
area.
- Simplified provider and model controls with cleaner, icon-focused
triggers while preserving accessible labels.
  - Updated repository and branch menus to open downward.
- Removed redundant leading icons from repository and branch selectors.

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

Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
Replace the visible "OpenAI" / "xAI" labels in provider authentication
controls with the existing logos.

The compact composer trigger now shows the logo plus the selected
account name. The session-options submenu and automations form labels
use the same logos. Accessible names and titles still include the
provider name.

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

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

## Summary by CodeRabbit

- **New Features**
- Provider authentication controls now display the relevant OpenAI or
Grok logo.
- Added accessible labels to authentication menus and selectors for
improved usability with assistive technologies.

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

---------

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

- document user-facing features and notable improvements merged from
August 16 through August 22
- add entries for provider accounts, session inbox organization,
managed-skill improvements, E2B prebuilt images, keyboard shortcuts,
automation activity, sandbox diagnostics, and model selection
- move the OpenCode Zen GLM 5.2 entry to its actual August 21 merge date

## Validation

- `npm exec prettier -- --check CHANGELOG.md`
- `git diff --check`

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

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

* **New Features**
  * Added unified model and reasoning selection.
* Added configurable keyboard shortcuts and automation activity
visibility.
  * Added managed provider accounts and managed-skill autocomplete.
* Added E2B image support, sandbox snapshots, and session attention
indicators.

* **Bug Fixes**
* Improved sandbox failure handling and clarified snapshot compatibility
behavior.

* **Documentation**
  * Updated release notes for August 16, 20, 21, and 22, 2026.
* Clarified provider account availability and sandbox error conditions.
  * Removed the OpenCode Zen GLM 5.2 entry.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

## What

Ports a guard that production already carries but public did not: a
sandbox WebSocket upgrade
against a session that is closed for good is rejected with `410 Session
is terminal`.

- `sandbox/lifecycle/decisions.ts` — adds
`SANDBOX_RECONNECT_BLOCKED_SESSION_STATUSES` and the
  `isSessionSandboxReconnectBlocked` predicate.
- `session/durable-object.ts` — adds the guard in
`handleWebSocketUpgrade`, immediately after
  sandbox token validation.

**Blocked membership is exactly `{archived, cancelled}`.** `created`,
`active`, `completed` and
`failed` are all allowed through.

That split is the part worth reading twice. `completed` and `failed`
read as terminal but are not —
a completed session is idle, not over. Warm-on-typing spawns a sandbox
for one *before* the
follow-up prompt arrives, so rejecting its bridge returned 410 to every
reconnect and stranded the
follow-up. Production hit exactly that bug and removed the two statuses
from the set. This PR
reproduces the post-fix membership verbatim rather than re-deriving it;
I confirmed no later
production commit re-tuned the set.

## Why

Public and prod had diverged here, and this file is about to be touched
by the SessionDO
decomposition. **This is Wave A, item 2 of 3 of that plan** — the point
of Wave A is to land the
small behavioural deltas first so the decomposition itself can be a pure
structural move rather
than a refactor that also silently changes admission behaviour.

## Guard placement (differs from the original port)

The guard sits **after** `isValidSandboxToken`, not before it. The first
draft of this PR placed it
before, and review caught a real defect:

`isValidSandboxToken` awaits `crypto.subtle.digest`. That is a
*non-storage* await, so the Durable
Object input gate does not hold other events back while it runs. A
cancel or archive landing
mid-hash left the pre-await status read stale, and the upgrade was
accepted (`101`) — attaching a
live bridge to a closed session and flipping the sandbox back to `ready`
via
`updateSandboxStatus("ready")`. This was demonstrated, not theorised:
the regression test below
failed with `expected 101 to be 410` before the guard was moved.

Reading after authentication also avoids widening an unauthenticated
status oracle. `/sessions/:id/ws`
routes straight to the DO with no worker-level auth, so a pre-auth guard
would have let an
unauthenticated caller holding only a session ID distinguish
archived/cancelled (`410`) from active
(`403`/`401`).

## Testing

- Unit: `isSessionSandboxReconnectBlocked` covered for all six
`SessionStatus` values, with the
blocked/allowed split declared explicitly per status rather than
computed from the set the
  implementation uses.
- Integration: archived and cancelled upgrades get `410`; completed and
failed upgrades still
connect and reach `ready`; and a mid-auth cancellation is re-checked
after the await, asserting
  both the `410` and that the sandbox stays `stopped`.
- Mutation-checked rather than just eyeballed. Adding
`completed`/`failed` back to the blocked set
fails 4 tests (the exact user-visible regression described above).
Disabling the guard fails 3,
including the mid-auth race. The archived/cancelled cases seed the
sandbox as `ready`, so the
pre-existing sandbox-status guard cannot produce a false-positive `410`.
- Full suites: 3042 unit tests and 965 integration tests pass;
`typecheck` clean across both
  tsconfigs; prettier and eslint clean.

## Deliberately not in scope

Both `decisions.ts` and `decisions.test.ts` are now byte-identical to
production, so they merge
clean on the next sync. `durable-object.ts` is **not** fully equivalent,
and two pre-existing
public gaps are left alone here:

1. Public's sandbox-status guard still runs pre-auth off a stale read;
prod re-reads it post-auth.
2. Public has no equivalent of prod's `Forbidden: Sandbox credentials
changed` check.

Both come from prod's `28fe6416`, which also touches
`session-status-service.ts`,
`websocket-manager.ts` and a conformance suite. Folding that in would
have made this PR a
behaviour change to existing authenticated paths rather than an additive
port. One user-visible
consequence of leaving it: when a session is terminal *and* the sandbox
is already stopped at
request time, public returns `410 Sandbox is stopped` where prod returns
`410 Session is terminal`
— same status code, different body. Worth closing separately as part of
the decomposition.


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

- **Bug Fixes**
- Prevented prompts, model selection, and sandbox connections for
archived or cancelled sessions.
- Improved handling of session status changes during sandbox
authentication.
- Completed and failed sessions can now connect and transition to ready
state correctly.

- **Tests**
- Added coverage for terminal session states, authentication races, and
rejected sandbox connections.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

## What

`SessionMessageQueue` and `SandboxLifecycleManager` were mutually
un-constructible:
the queue takes the manager as a constructor argument, and the manager
took the
queue's two termination callbacks as a constructor argument. `SessionDO`
only got
away with it because both getters are lazy and the callback literals
deferred the
`this.messageQueue` lookup until fire time.

This PR moves the callbacks out of the manager's constructor and behind
a new
`setCallbacks(callbacks: LifecycleCallbacks): void`:

- `manager.ts` — `callbacks` goes from a `readonly` constructor
parameter to a
private field wired once by `setCallbacks`. All 7 call sites still
invoke them
optionally (`?.()`), so an unwired manager is fully functional and just
skips
  the notification, exactly as before.
- `durable-object.ts` — the `lifecycleManager` getter constructs the
manager and
then wires it. `createLifecycleManager()` no longer references
`messageQueue`
  at all.

`setCallbacks` is deliberately **guarded, not idempotent**: a second
call throws.
This is one-time wiring by whoever owns the manager's construction; a
second
caller means two owners disagree about which collaborator gets notified,
and
letting the last one win would silently drop terminations. Documented on
the
method.

## Why

Part of the SessionDO decomposition plan — **Wave A, item 3 of 3**. This
was the
only construction cycle in the SessionDO object graph. Removing it is
what lets a
later phase replace the lazy getters with a composition root that builds
the graph
eagerly.

To be precise about what is and is not done here: the callbacks still
resolve
`this.messageQueue` lazily at fire time, so the DO has not yet stopped
depending
on lazy construction. What changed is that the cycle is no longer forced
by the
constructor signatures — an eager composition root can now pass a
concrete
`messageQueue` reference, which was impossible before. The DAG property
is
**enabled, not yet exercised**; Phase 2 removes the getters and does the
rest.

## Behaviour

Zero functional change. The two closures are textually identical to the
ones they
replace and are wired before the manager is reachable by anything, so
the callbacks
fire in exactly the same situations.

One signature note: `imageBuildLookup` shifts from constructor arg 9 to
arg 8.
That is safe by construction — `ImageBuildLookup` has two required
members, so any
caller still passing a `LifecycleCallbacks` literal in that slot fails
typecheck
rather than binding silently.

## Testing

- `packages/control-plane/src/sandbox/lifecycle/manager.test.ts` — new
`setCallbacks`
block: callbacks fire on the heartbeat-stale and
`terminateUnresponsiveSandbox`
paths after post-construction wiring; an unwired manager does not throw
on a
terminated path; a second wiring throws and leaves the first wiring
intact.
  Existing tests that passed callbacks positionally were rewritten to
  `manager.setCallbacks(...)` — none deleted.
-
`packages/control-plane/test/integration/session-lifecycle-callbacks.test.ts`
—
new. Drives a real `SessionDO`: parks its sandbox past the connecting
timeout with
a `processing` message outstanding, runs the alarm, and asserts the
message is
failed. Plus one test that the DO wires the manager exactly once. Added
because
  review found the DO-side wiring had no coverage at all — deleting the
`setCallbacks` call left the entire unit suite green, and the production
failure
mode is silent (a `processing` message stuck forever, no error
anywhere). Both
new tests were mutation-verified: they fail when the wiring call is
removed.
- `npm test -w @open-inspect/control-plane` — 195 files / 3041 tests
pass.
- `npm run typecheck -w @open-inspect/control-plane` — clean.
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/session-lifecycle-callbacks.test.ts` — 2 tests pass.
- prettier + eslint clean on all changed files.

## Not done / deferred

- The full integration suite was not run locally, only the new file.
Nothing here
changes a store or D1 signature, and no pre-existing integration test
references
  the callback names. CI shards the suite.
- The lazy getters in `durable-object.ts` stay, by design — Phase 2
removes them.


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

* **Bug Fixes**
* Improved recovery when sandbox operations fail, time out, or become
unresponsive.
* Stuck processing messages are now marked as failed during lifecycle
recovery.
  * Queued prompts automatically resume after sandbox termination.
  * Stop-confirmation state is cleared during recovery.

* **Tests**
  * Added integration coverage for connecting-timeout recovery.
* Expanded coverage for sandbox failures, termination outcomes, and
queue resumption.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

## What

Test-only change. Adds four integration tests covering the **eviction /
hibernation restore path**
of `SessionDO`, in the existing
`test/integration/durable-object.test.ts` under a new
`eviction and hibernation restore` describe block:

1. **Client prompt on a reconstructed instance** — a client frame routed
on an instance that has not
run `ensureInitialized()` in memory is still handled, and the prompt is
persisted against the
   right participant.
2. **Alarm on a reconstructed instance** — an alarm delivered to a
reconstructed instance runs and
   its stuck-message watchdog fails the message.
3. **Client identity recovered from storage** — with the in-memory
`ClientInfo` cache gone, a socket
whose row survives in `ws_client_mapping` is recovered, and every
rebuilt identity field
(participant id, canonical user id, name, avatar) is asserted through
the resulting
   `presence_update` broadcast.
4. **Missing mapping closes the socket** — the negative case: no mapping
row gives close code 4002.

Eviction is forced with `ctx.abort()`, which discards every in-memory
field while leaving the DO's
SQLite intact. A test-only marker is stamped onto the instance before
the abort and asserted absent
afterwards, so the tests cannot silently stop exercising reconstruction
if the harness ever makes
`abort()` a no-op.

The only other change is in `test/integration/helpers.ts`:
`openClientWs` gains optional `scmLogin`
and `scmName` passthroughs. These are additive optionals, so
`JSON.stringify` drops them and the
`/internal/ws-token` request body is byte-identical for all 45 existing
call sites.

**No `src/` changes.** `git diff origin/main...HEAD --name-only` is
exactly those two test files.

## Why

Wave A, item 1 of 3 of the SessionDO decomposition plan.
`src/session/durable-object.ts` (2022
lines) is about to have its object graph rebuilt by a composition root.
The request path is already
well covered by the existing integration suite (960 tests before this
PR), but the thin spot is what
happens when a callback lands on a **freshly reconstructed** DO
instance: `ensureInitialized()`
rebuilding the graph, and
`getClientInfo()` rebuilding `ClientInfo` from `ws_client_mapping` when
the in-memory cache is gone.
Before this PR, nothing caught a refactor that broke either one. This is
the safety net going in
first, ahead of the refactor it is meant to protect.

## How it was tested

Each test was verified to fail when the behaviour it covers is
deliberately broken in `src/`, then
`src/` was reverted. Confirmed red for:

- removing `ensureInitialized()` from `SessionServer.onMessage` (tests 1
and 3 fail)
- removing `ensureInitialized(false)` from
`SessionServer.onScheduledDeadline` (test 2 fails)
- removing the `close(ws, 4002, ...)` call from
`SessionDO.getClientInfo` (test 4 fails)
- changing `getClientInfo` to use `mapping.user_id` for both `userId`
and `name` (test 3 fails on
  both fields)

Green on this branch:

- `test/integration/durable-object.test.ts`: 9 passed (5 pre-existing,
byte-identical, plus 4 new)
- full control-plane integration suite: 76 files / 964 tests, exit 0, no
`EnvironmentTeardownError`
- control-plane unit suite: 195 files / 3036 tests
- `npm run typecheck -w @open-inspect/control-plane`: clean
- `prettier --check` and `eslint`: clean on both changed files

### Expected log noise

`ctx.abort()` makes workerd print two lines per run:

```
uncaught exception; exception = workerd/api/actor-state.c++:1142: failed: broken.outputGateBroken; jsg.Error: test: force eviction
```

This is a runtime log line, not a vitest failure. The suite still exits
0. Expect it in CI logs.

## Review follow-up applied

The second commit removes the one timing dependency in the new code. The
restored-socket helper
originally slept a fixed 100ms waiting for frames and close events to
cross the `WebSocketPair`; it
now settles on an `until` predicate or on the close event, with the same
2000ms fallback
`collectMessages` uses. `collectMessages` itself could not be reused
because it only observes
frames, and the 4002 case asserts on a close event. Confirmed the wait
still has teeth: under the
break-tests above the failing cases hit the 2000ms fallback rather than
settling vacuously, and
target-file runtime dropped from 529ms to 219ms.

## Deferred, with reasons

- **`test/integration/tsconfig.json` reports 10 more errors than
baseline** (834 to 844). Being
precise since an earlier note in review claimed "no new errors", which
was wrong. All 10 are the
same pre-existing class already present at baseline: missing
`@cloudflare/workers-types` globals
and `cloudflare:test` members (`TS2304
DurableObjectStub`/`WebSocketPair`, `TS2339` `ctx` on
`SessionDO`, `TS2305` on `cloudflare:test` exports, `TS7006` on
`addEventListener` params), caused
by that config setting `types: ["@cloudflare/vitest-pool-workers"]`.
That tsconfig is referenced
by no npm script and no CI job, and both `tsconfig.json` and
`tsconfig.test.json` include only
`src/**/*.ts`, so `npm run typecheck` does not cover these files at all.
Zero CI impact, and
  fixing the config is out of scope for a test-only PR.
- **Test 4 overlaps existing coverage.** `websocket-client.test.ts`
already has "rejects typing
before subscribing", which hits the same null-mapping branch, and the
eviction is not load-bearing
for it. Kept anyway: it was an explicitly requested scenario and it is
the only test asserting the
  close reason string.
- **Test 2 does not pin the `rehydrateAlarm = false` argument.** Not
added, because
`src/session/server.test.ts:357` already asserts
`expect(ensureInitialized).toHaveBeenCalledWith(false)`
directly, so the regression this would guard against is already caught.
An integration-level proxy
  assertion would be strictly weaker and more brittle.


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

* **Bug Fixes**
* Improved reliability when sessions are restored after eviction or
hibernation.
  * Ensured queued prompts continue processing after session recovery.
* Ensured timed-out messages are correctly marked as failed after
recovery.
* Preserved client identity and presence details when reconnecting
restored sessions.

* **Tests**
* Added integration coverage for session eviction, WebSocket
restoration, alarm handling, message persistence, and client identity
recovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

## Phase 1.5 of the SessionDO decomposition plan

`SessionDO` handed itself out to its own collaborators through `=>
this.<method>` closures, and a
large share of those targeted **one-to-three-line forwarder methods**
that did nothing but call a
collaborator the DO already held. This PR deletes those forwarders and
repoints every caller at the
collaborator directly, plus hoists seven DO methods that only ever
needed platform scalars into
module-level pure functions.

Mechanical and behaviour-preserving. No route, schema, or message-shape
changes.

### Stacking note — read this

This was written as a stacked PR on top of **ColeMurray#1578**
(`refactor/break-lifecycle-message-queue-cycle`).
**ColeMurray#1578 has since merged**, its branch was deleted upstream, and the
merged version differs from the
branch head this work was based on (the callbacks moved into
`session/alarm/handler.ts` rather than a
`setCallbacks` method). The branch has therefore been **rebased onto
`main`** and this PR targets
`main` directly. There is nothing left to merge first.

The rebase required two adaptations to code that landed in the meantime,
both in their own commits:

- **ColeMurray#1577** added a terminal-session admission check calling
`this.getSession()` in the sandbox WS
  upgrade path. Repointed to `this.sessionCoreRepository.getSession()`.
- **ColeMurray#1577**'s `revalidates terminal state after asynchronous
authentication` test spied on the DO's
private `isValidSandboxToken`, which this PR hoists to a module
function. The test now drives the
same race from the pre-authentication sandbox read, which queues the
cancel as a microtask that
lands inside the token-hash await, and additionally asserts the response
body so it can only pass
via the post-authentication session read. Verified the guard still
bites: moving that session read
back above the await turns the upgrade into a `101` and fails the test.

### Numbers, and what they do NOT mean

| | before | after |
|---|---|---|
| `durable-object.ts` lines | 2040 | 1843 (**-197**) |
| `=> this.` occurrences in `durable-object.ts` | 127 | 119 (**-8**) |

The line delta is real: 22 method bodies plus their doc comments are
gone, and ~215 lines of that
moved into small, separately testable modules.

**The back-edge delta is small on purpose, and it does not mean the DO
is decoupled.** The closures
were retargeted, not removed: `getSession: () => this.getSession()`
became
`getSession: () => this.sessionCoreRepository.getSession()`. That is
still a closure over `this`. What
changed is the *shape* of the coupling — collaborators now reach a
repository or a service instead of
bouncing off a DO method that exists only to forward. The DO is still
the composition root, still
holds every collaborator, and 119 closures still resolve DO fields at
call time. Removing that
requires a real composition root, which is Phase 2b.

Of the 119 remaining, 35 are the `routes` dispatch table (legitimate,
explicitly out of scope) and 13
target genuine DO domain logic left for Phase 2a/3 (`ensureInitialized`,
`handleSubscribe`,
`handleSnapshot`, `handleSandboxAccess`, `applySessionTitleUpdate`,
`ensureRepoId`,
`getProviderAuthenticationError`, `getUserEnvVars`,
`schedulePullRequestRefresh`,
`loadMemberRepoSecrets`, `getClientInfo`, `handleWebSocketUpgrade`).

### Deleted — 15 forwarder methods

`getSession`, `getSandbox`, `updateSandboxStatus`, `safeSend`,
`broadcast`, `spawnSandbox`,
`warmSandbox`, `triggerSnapshot`, `updateLastActivity`,
`scheduleInactivityCheck`,
`processMessageQueue`, `stopExecution`, `processSandboxEvent`,
`pushBranchToRemote`,
`handlePromptMessage`.

All were `private` with no external callers. Every call site is
repointed with the argument list
preserved. Two carried doc comments their collaborators lacked
(`stopExecution`'s synthetic-event
contract, `pushBranchToRemote`'s wait-for-completion contract); those
comments moved down to
`SessionMessageQueue.stopExecution` and
`SessionSandboxEventProcessor.pushBranchToRemote` rather than
being lost.

### Hoisted — 7 methods to module-level pure functions

| function | new home |
|---|---|
| `getPublicSessionId` → `resolvePublicSessionId` |
`session/public-session-id.ts` |
| `isValidSandboxToken`, `decryptStoredAccessValue`,
`getSandboxDashboardUrl` → `resolveSandboxDashboardUrl` |
`session/sandbox-access.ts` |
| `resolveScmSettings` | `session/scm-settings-resolution.ts` |
| `parseArtifactMetadata` | `session/artifact-metadata.ts` (existing) |
| `safeParseTunnelUrls` | `session/tunnel-urls.ts` (existing) |

`parseArtifactMetadataJson` and `parseTunnelUrls` are reused, not
duplicated. Each hoisted helper got
direct unit tests — they no longer need a Workers runtime, which is the
point. The sandbox-token tests
are load-bearing: nothing else in either suite covered hash-vs-plaintext
precedence.

`resolvePublicSessionId` dropped a `?? this.getSession()` fallback that
was provably dead — all seven
call sites either guard on `if (!session) return` or are typed
non-nullable by their dependency
contract.

### The constraint this change had to respect

`this.lifecycleManager` is a lazy getter whose construction calls
`createSandboxProviderFromEnv`,
which **throws** on missing provider env. Every thunk stayed a thunk;
only the resolution target
changed. Confirmed mechanically with an AST pass over both files:
construction-time `this.<member>`
reads (constructor body, getter body, field initialiser — i.e. not
inside an arrow) went 129 → 130,
and the single new one is the *write* to `sandboxDashboardSettings`.

One site got this wrong on the first pass and is fixed in `fix: keep
warm-spawn getter read inside the
background task`. Deleting the `async warmSandbox()` wrapper moved the
getter read into argument
position of `backgroundTasks.submit(...)`, converting a swallowed
promise rejection into a synchronous
throw that escaped `POST /internal/init` — a 500 on session create
*after* the session, sandbox and
participant rows were already committed. The read now happens inside the
submitted promise.

`sandboxDashboardSettings` is a new constructor-time snapshot of three
plain optional `env` strings
(`SANDBOX_PROVIDER`, `MODAL_WORKSPACE`, `MODAL_ENVIRONMENT`). A DO's
`env` is fixed for the instance's
lifetime, so this is observationally identical and cannot throw.

### Kept, with reasons

- **The `routes` dispatch table.** Its closures are legitimate dispatch,
not forwarding. Untouched.
- **`createSourceControlProvider()`** — a literal one-liner, but it is
not the target of any `=> this.`
closure, so deleting it removes zero back-edges. It belongs to Phase
2b's lazy-getter removal.
- **`getIsProcessing()`** — one line, but it adds a predicate over
`getProcessingMessage()`; single
  internal caller, no back-edge.
- **`fetch` / `webSocketMessage` / `webSocketClose` / `webSocketError` /
`alarm`** — pure forwards to
  `this.server`, but they are the DurableObject platform contract.

### Tests

Deletions of untested forwarders can't be TDD'd literally, so each
deleted edge was checked for
existing coverage first. Two had none that could see it, and both had a
**success-shaped fallback**
that hid the gap — a silent stand-in for either left all 975 integration
tests green:

- `triggerSnapshot` is fire-and-forget through `backgroundTasks.submit`,
and its only unit test injects
  a `vi.fn()` for the dep, so it cannot see the DO's wiring.
- `pushBranchToRemote` returns `{ success: true }` when no sandbox is
connected — exactly what a
dropped edge returns — and `create-pr.test.ts` never opens a sandbox
socket.

New `test/integration/session-do-collaborator-wiring.test.ts` pins six
edges. Each was verified by
mutation (replace the thunk with a plausible stand-in, confirm exactly
one test fails, revert):

| guard | mutation it kills |
|---|---|
| typing → `lifecycleManager.spawnSandbox` | no-op the spawn |
| `execution_complete` → `lifecycleManager.triggerSnapshot` |
`Promise.resolve(void reason)` |
| create-PR → `sandboxEventProcessor.pushBranchToRemote` | fake `{
success: true }` |
| init survives an unbuildable provider | read the lazy getter in
argument position |
| snapshot surfaces stored `tunnel_urls` | hardcode `tunnelUrls: null` |
| snapshot falls open on a corrupt blob | (asserts the blob survived, so
it can't pass vacuously) |

The tunnel-URL cases deliberately leave the sandbox row in its terminal
`failed` state rather than
reviving it to `ready`. The snapshot reads `tunnel_urls` regardless of
status, and `failed` is a dead
status the lifecycle alarm early-returns on — reviving the row re-armed
an alarm that clears tunnel
URLs, which is what made an earlier draft of these tests flaky under
load.

Also deleted a vacuous `resolveScmSettings` test whose `prepare` spy
hung off a `db` the call never
received; it passed for every possible implementation.

### Verification

- `npm run typecheck -w @open-inspect/control-plane` — clean (both
tsconfigs)
- `npm test -w @open-inspect/control-plane` — 198 files / 3061 tests
passed
- `npm run test:integration -w @open-inspect/control-plane` — 79 files /
975 tests passed, twice
  consecutively from a clean tree
- prettier + eslint clean on all changed files

### Deferred

- **`(reason) => this.lifecycleManager.triggerSnapshot(reason)` at the
sandbox-event processor is not
wrapped in an async IIFE like the warm-spawn site.** Deliberate. On that
path the two statements
immediately after it (`updateLastActivity`, `await
scheduleInactivityCheck`) resolve the same getter
and were *already* synchronous-throwing on the base branch, so a
misconfigured provider produces the
same failure with or without the wrapper. Adding an unexplained `async`
there would be noise.
- **`session-status-service.ts` carries its own `getPublicSessionId`**
that lacks the DO-id fallback.
Consolidating it is a semantic decision, not a mechanical one, and it is
outside `durable-object.ts`.
- **`getSessionUrl` still inlines `session.session_name || session.id`**
(two-arm, no DO-id fallback).
Routing it through `resolvePublicSessionId` would add a third arm — a
behaviour change, not a hoist.
- **No test asserts that a lazy getter is never forced during
construction.** The integration config
supplies `MODAL_API_SECRET` and `MODAL_WORKSPACE`, so
`createSandboxProviderFromEnv` never throws in
CI. The new "init survives an unbuildable provider" case covers the
warm-spawn path specifically by
  shadowing the getter, but a general guard belongs with Phase 2b.
- **Phase 2a/3 work** — the five domain responsibilities (connection
auth, snapshot/read model, secrets
assembly, sandbox access, title) are untouched, as is anything
resembling a composition root.


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

- **New Features**
- Improved session and sandbox access with secure token validation,
credential handling, and dashboard URL resolution.
- Added fallback resolution for public session identifiers and
source-control settings.
- Added safer parsing for artifact metadata and tunnel URLs, with
warnings for invalid data.

- **Bug Fixes**
- Improved handling of corrupted metadata, tunnel data, missing
credentials, and session terminal-state races.

- **Tests**
- Expanded coverage across session, sandbox, source control, metadata,
tunnel, and WebSocket behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

Supersedes ColeMurray#1471 with its merge conflict resolved and the review
findings addressed.

- imports and re-imports managed skills from GitHub or GitLab
repositories with pinned, review-before-write provenance
- verifies the full mapped revision at confirmation time and ignores
stale UI preview responses
- rejects symlinks, submodules, oversized streaming blobs, invalid YAML,
and provider-mismatched re-imports
- exposes every bounded supporting file in the confirmation preview,
including executable script contents
- restricts installation-wide skill and profile administration to
authenticated human users

## Verification

- shared unit tests: 678 passed
- control-plane unit tests: 3,099 passed
- web unit tests: 1,212 passed
- managed skill import integration tests: 16 passed
- shared, control-plane, and web typechecks passed
- control-plane and web lint passed
- control-plane and web production builds passed
- exact-head focused verification: 228 control-plane tests and 3 web
tests passed

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

- **New Features**
  - Import managed skills directly from GitHub or GitLab repositories.
- Preview source files, metadata, warnings, commit details, and content
digests before importing.
- Re-import existing skills with source tracking, unchanged-content
detection, and revision protection.
  - Display repository provenance in the skills catalog.
- **Bug Fixes**
  - Prevent stale import previews from overwriting current results.
- Improve handling of repository paths, oversized files, invalid
content, and conflicting skill names.
- **Documentation**
- Document repository imports, validation, provenance, re-import
behavior, and related roadmap plans.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: MhAhmadAli <fa17-bsse-023@lgu.edu.pk>
Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com>
## Summary
- add understated creator attribution to each shared skill row
- fall back to the canonical creator ID when no display name is
available
- keep assignment and creator metadata responsive on narrow screens
- add focused catalog coverage for display-name and ID rendering

## Verification
- `npm test -w @open-inspect/web --
src/components/settings/skills-settings/skills-catalog.test.tsx`
- `npm run typecheck -w @open-inspect/web`
- `npm run lint -w @open-inspect/web --
src/components/settings/skills-settings/skills-catalog.tsx
src/components/settings/skills-settings/skills-catalog.test.tsx`
- visually verified at 1440x900 and 390x844 viewports

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

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

* **New Features**
* Skill catalog entries now display the creator’s name or ID alongside
the assignment count.
* Metadata wraps automatically for improved readability in narrow
layouts.
* When a creator name is unavailable, the catalog falls back to
displaying the creator ID.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

This is the commit-signing half of closed PR ColeMurray#1580.

The bridge initializes commit signing before opening its WebSocket. The
configuration fetch previously collapsed every `httpx.HTTPError` into
one status-less `GitSigningError`, so terminal 401/403/404/410 responses
were treated like transient network failures and retried indefinitely.

## Changes

- preserve the HTTP response status on redacted commit-signing fetch
errors
- make retryability explicit on `GitSigningError`
- classify network and non-terminal HTTP failures as retryable
- classify terminal authorization/not-found responses, malformed
payloads, invalid manifests, and local Git configuration failures as
non-retryable
- let the bridge consume the signing boundary’s retryability decision
instead of owning broker-specific status policy
- retain fixed error messages without response bodies, URLs,
authorization headers, or secret details

## Validation

- `PYTHONPATH=src uv run --extra dev ruff check src tests`
- `PYTHONPATH=src uv run --extra dev pytest tests/test_git_signing.py
tests/test_bridge_reconnection.py -q` (43 passed)

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved Git signing error handling to distinguish temporary failures
from terminal failures.
* Authentication, authorization, and unavailable signing responses now
stop processing immediately instead of repeatedly reconnecting.
* Temporary network and service errors continue to support retry
behavior.
* Error messages remain sanitized and do not expose sensitive upstream
details.

* **Tests**
* Added coverage for terminal HTTP statuses, retryable failures, and
non-retryable signing errors.

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

---------

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

## What

Extracts the user-env responsibility (~145 lines, 5 methods) out of
`SessionDO` into two focused units:

- **`UserEnvResolver`** (`session/user-env-resolver.ts`) — resolves the
user-defined environment a session's sandbox receives (decrypted
global/repo/environment secrets folded with precedence, plus the
managed-provider env derived from the session's provider auth modes) and
answers whether a model's provider has usable authentication in that
environment. Public: `getUserEnvVars`, `getProviderAuthenticationError`.
- **`resolveSessionRepoId`** (`session/repo-id-resolution.ts`) —
resolves (and persists) the primary repo id for legacy session rows that
predate `repo_id`. Split out per review: it is repository identity, not
environment assembly. The two token-refresh services and
`UserEnvResolver` receive the same capability, so the resolver carries
**no `SourceControlProvider` dependency**.

Behaviour-preserving; method bodies moved verbatim modulo dependency
substitution. The name follows the codebase's existing vocabulary for
exactly this data (`getUserEnvVars`, "user-defined secrets" vs system
vars); "Env(ironment)" alone was avoided because capital-E Environments
are a different feature (`environment_id`, `EnvironmentSecretsStore`),
and `sandbox-env.ts` already names the SESSION_CONFIG wire contract.

This is the follow-on to ColeMurray#1577/ColeMurray#1578/ColeMurray#1579/ColeMurray#1583: `SessionDO` cannot
shrink by extraction alone (each extraction historically left a `() =>
this.x` back-edge behind at ~1:1), so the plan is to build an eager
composition root next — and this unit is the **only** domain
responsibility that composition root itself depends on
(`SandboxLifecycleManager` needs `getUserEnvVars`, `SessionMessageQueue`
needs `getProviderAuthenticationError`). It jumps the queue so the root
can be built without reaching back into the DO.

## Design notes

- **The SCM provider stays behind a thunk at the `resolveSessionRepoId`
call sites, deliberately.** `createSourceControlProviderFromEnv` throws
on reachable configs (invalid `SCM_PROVIDER`; `gitlab` without
`GITLAB_ACCESS_TOKEN`; `bitbucket` unconditionally), and repo-id
resolution only needs it for legacy rows — sessions that already carry
`repo_id` never construct it. The composition-root PR is the right place
to decide between upfront config validation + eager construction vs.
keeping the deferral.
- **`log` is a thunk, deliberately.** The DO replaces `this.log` with a
session-scoped logger inside `ensureInitialized()`; a by-value capture
pins whichever logger existed at construction (`SandboxRepository` has
exactly this live defect today — its `sandbox.status.unrecognized`
warnings lack `session_id`).
- The DO gains one lazy getter (`userEnvResolver`) wired in the existing
getter pattern; the four consumer edges (message-queue provider-auth
error, two token-refresh repo-id edges, lifecycle `getUserEnvVars`) are
rewired.
- Env is passed as two scalars (`repoSecretsEncryptionKey`,
`secretsCapEnforcement`), not the whole `Env`.

## Metrics

- `durable-object.ts`: 1850 → 1719 lines.
- `=> this.` count: 118 → 119 (**expected to go up by one net**: the
rewired edges still thunk through the DO getter for now; the
composition-root PR is what deletes the getters and thunks wholesale.
This PR's job is moving the logic, not the wiring).

## Tests

- **22 new unit tests** across `user-env-resolver.test.ts` (18) and
`repo-id-resolution.test.ts` (4) — the first decomposition units
testable without a Workers runtime. The resolver harness runs a **real**
`SessionCoreRepository` over a fake DO `SqlStorage` and **real** secret
stores over a fake `SqlDatabase` with real AES-GCM round-trips, so the
tests pin the production throw/return surface, not the fakes. Its
default `resolveRepoId` is the real function over a throwing provider
thunk, mirroring production wiring. Coverage includes: the no-session
and D1-missing surfaces, the no-encryption-key path,
`undefined`-not-`{}` on empty env, the managed-broker source filter
(secondary repos excluded unless environment-launched), all four repo-id
branches, lazy repo-id resolution for a legacy primary member, an
id-less secondary member contributing nothing, secrets-cap enforcement
(fail-closed enforce → `SecretsCapExceededError`; `warn` mode proceeds
and logs — fixture built from the exported size constants as three
individually write-valid scopes whose sum exceeds the combined cap, with
the arithmetic asserted inline), and both injection contracts (logger
swap honored after construction; a secrets-only load never constructs
the SCM provider).
- Integration safety net: full control-plane integration suite green (80
files / 995 tests), including `session-secrets-fold`,
`session-from-environment`, `do-internal-routes`, and the
collaborator-wiring guards.
- The two secrets integration files called the DO's (now deleted)
private method directly via `runInDurableObject`; they now share one
`Pick`-typed accessor helper (`test/integration/session-do-access.ts`)
so a rename breaks the helper instead of silently passing. Every
assertion is unchanged.

Follow-up tracked separately: ColeMurray#1588 (broker policy currently keyed on
`SecretSource.label`; moved verbatim here, structural fix deferred
deliberately).

Typecheck (both tsconfigs), full unit suite, and Prettier are green.


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

* **New Features**
* Session environments now combine global, repository, and environment
secrets consistently.
* Managed-provider credentials and authentication errors are surfaced
through session environments.
* Missing repository identifiers can be resolved and saved
automatically.
  * Unavailable secondary repositories are skipped safely.

* **Bug Fixes**
  * Improved handling of missing sessions and inaccessible repositories.

* **Tests**
* Expanded coverage for secret merging, provider authentication,
repository resolution, persistence, and environment limits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…uthentication (ColeMurray#1586)

## What

Completes the sandbox-WebSocket admission hardening started in ColeMurray#1577.
`isValidSandboxToken` awaits `crypto.subtle.digest` — a **non-storage**
await — so the Durable Object input gate does not hold other events
across it: a cancel, archive, stop, or credential rotation can land
mid-hash. ColeMurray#1577 moved the *session*-status guard behind authentication
onto a fresh read; this PR does the same for the remaining state checks:

1. The sandbox-status guard (`410 Sandbox is stopped`) moves from
pre-auth to post-auth and now operates on a **fresh**
`sandboxRepository.getSandbox()` re-read.
2. New consistency check: if the sandbox row's `modal_sandbox_id`,
`auth_token_hash`, or `auth_token` changed while the request was
suspended in token hashing → `403 Forbidden: Sandbox credentials
changed`.
3. `SessionWebSocketManagerImpl`'s hibernation-recovery loop now closes
(`1000, "Sandbox identity changed"`) a cached socket whose tag doesn't
match the persisted sandbox ID, instead of just skipping it.

Guard order after this PR: sandbox-ID mismatch `403` (pre-auth — pure
synchronous comparison, nothing can go stale before it) → token `401` →
fresh session read `410` → fresh sandbox read `410` → consistency `403`.
Our production deployment has run exactly this ordering since Aug 18.

## Externally visible contract changes (both verified safe for the
bridge)

- Stopped sandbox + **invalid token**: `410` → `401`.
- Stopped sandbox + **wrong X-Sandbox-ID**: `410` → `403 Wrong sandbox
ID` (the stopped-check used to run before even the ID check).

The only in-repo consumer is the sandbox bridge
(`packages/sandbox-runtime/src/sandbox_runtime/bridge.py`): a non-101
upgrade raises `InvalidStatus`, and `bridge.py` funnels 401/403/404/410
into the same terminal `SessionTerminatedError` — one attempt, no retry,
clean shutdown (`test_bridge_reconnection.py` pins all four). No
retry-forever risk.

Security-wise the unauthenticated surface **shrinks**: lifecycle state
(`stopped`/`stale`) is no longer revealed to callers without a valid
token; an unauthenticated probe now sees only `401`/`403`.

## Red-first evidence

The three new integration tests were written first and run against the
unmodified base:

- sandbox stopped mid-auth → expected `410`, got `101` (pre-auth guard
read the pre-flip row)
- credentials rotated mid-auth → expected `403`, got `101` (no
consistency check existed)
- stopped sandbox + invalid token → expected `401`, got `410`
(stopped-check ran pre-auth)

All three fail on base and pass with the change; the pre-existing
session-terminal race test passes unchanged (its assertions are
untouched — only two comments that described the old ordering were
updated, and its inline spy was folded into the shared
`mutateSandboxDuringAuth` helper). The new websocket-manager assertion
was likewise red on base (`close` never called) and green now.

Also strengthened `sandbox-events.test.ts`'s
duplicate-`execution_complete` test with four lifecycle-reconciliation
assertions (snapshot trigger, activity bump, inactivity reschedule,
queue drain) — pinning existing behavior.

## Review updates

- Per review, the recovery loop now also **skips and closes untagged
sandbox sockets** when a persisted sandbox ID exists (`43b4d0f5c`,
red-first verified) — matching the production recovery loop exactly. The
cached-socket per-call identity re-validation remains deliberately out
of scope.
- The deeper pre-persistence replacement race surfaced in review (a
stale bridge authenticating between replacement start and
`updateSandboxForSpawn` persisting) is **pre-existing** — the old
pre-auth guard had the same window and production has run this exact
ordering since Aug 18. This PR narrows admission races to that one
window; closing it is a lifecycle-ordering change tracked as ColeMurray#1589 with
two designed fixes.

## Known gaps, deliberate

- The credentials-changed `403` emits no `ws.connect` log event (parity
with the production ordering as deployed; a follow-up can add a
`reject_reason` for observability).

Typecheck, full unit suite, full integration suite (80 files / 998
tests), and Prettier are green.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved sandbox WebSocket reconnection handling when sandbox identity
or credentials change during authentication.
* Prevented stale or unidentified connections from being restored after
sandbox identity changes.
* Ensured stopped sandboxes and invalid credentials return the correct
authentication response without exposing lifecycle details.
* Improved reliability when sandboxes shut down during connection or
authentication.
* Duplicate execution completions now consistently trigger expected
session updates and queued message processing.

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

## What

`BackgroundTasks.submit` now takes a **task factory** (`() =>
Promise<unknown>`) instead of an already-started promise, and invokes it
synchronously inside try/catch. A synchronous throw while building the
task — the canonical case being a lazy getter whose construction throws
on missing provider env — becomes a logged `background_task.failed`
instead of escaping the caller.

This is the follow-up promised in the ColeMurray#1583 review: that PR had to keep
a named `warmSandbox()` method alive purely as an async boundary,
because with `submit(promise)` the safety of the call depended on the
call-site expression shape. With the factory signature the boundary is
structural, so this PR **deletes that method** (and its 11-line
justification comment) and inlines `() =>
this.lifecycleManager.warmSandbox()` at the one call site.

## Why synchronous invocation (not `Promise.resolve().then(task)`)

Under the old signature, the task expression always executed
synchronously before `submit` was entered. Invoking the factory
synchronously preserves that timing exactly at all 23 call sites; a
microtask deferral would change execution order everywhere for zero
benefit. The observable contract is identical either way: synchronous
throws are absorbed and logged, `waitUntil` is only called with a real
promise. A unit test pins the synchronous-execution semantics so a
future "cleanup" to microtask deferral fails loudly.

## Scope

- 23 production call sites across 11 files converted to factories; 7
test fakes updated to invoke the factory (so background work still runs
in tests with the same timing as before).
- Four sites passed a pre-created promise variable; three
(`webhooks/github.ts`, `image-builds/save-hooks.ts`,
`message-queue.ts`'s projection chain) moved creation inside the factory
— for `save-hooks.ts` this also pulls the synchronous workflow
construction inside the absorbed boundary, killing the exact bug class
this refactor targets.
- The fourth, `routes/repos.ts`, deliberately keeps one shared promise:
the HTTP response awaits the **same** refresh run that `submit` keeps
alive past client disconnect, so moving creation inside the factory
would double-execute the SCM refresh. `submit(() => refresh)` + a
comment; safe because `refreshReposCache` is an `async function` and
cannot throw synchronously.
- `test/integration/session-do-collaborator-wiring.test.ts` still passes
both guards for the warm-spawn edge — the shadowed-getter read counter
and the provider-env-throw test (`/internal/init` returns 200 with the
throw absorbed and logged). Its mechanism comment now describes the new
contract.

## Verification

Red-first on the implementation's unit tests (4/4 failed against the old
code with `TypeError: task.catch is not a function`, green after).
Typecheck (both tsconfigs — noting `test/integration/**` is not
typechecked, the converted paths there were exercised by running 7
integration files), full unit suite, full integration suite (80 files /
995 tests), Prettier all green.


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

* **Bug Fixes**
* Improved reliability of background operations by handling immediate
and asynchronous failures.
* Prevented background-task setup errors from interrupting primary
request processing.
* Preserved error logging across session, repository, image-build,
webhook, and notification workflows.

* **Refactor**
* Background work is now deferred until scheduled, avoiding unnecessary
early execution.
* Updated related workflows and test coverage to support consistent task
scheduling and failure handling.
* Added shared testing support for verifying submitted tasks and
captured failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- skip the main CI workflow when a pull request or push changes only
Markdown files or content under `docs/`
- continue running full CI whenever documentation changes are mixed with
code or configuration changes

## Motivation

PR ColeMurray#1571 changed only `CHANGELOG.md`, but triggered all 14 CI jobs. This
is the first, narrowly scoped improvement in a broader effort to make CI
package-aware.

## Validation

- `npx prettier --check .github/workflows/ci.yml`
- `git diff --check`

`actionlint` was not available in the local environment.

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

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

## Summary by CodeRabbit

* **Chores**
* Updated continuous integration triggers to skip documentation-only
changes, reducing unnecessary workflow runs for Markdown and
documentation updates.

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

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

## What

Phase 2b+3 of the SessionDO decomposition. All construction and wiring
moves out of `SessionDO` into `createSessionRuntime(platform, env)`
(`session/components.ts`) — an eager, topologically-ordered composition
root that returns a **narrow runtime surface**, not the graph:

```ts
interface SessionRuntime {
  readonly log: Logger;
  readonly server: SessionServer<WebSocket, ClientInfo>;  // onRequest / onMessage / onClose / onError / onScheduledDeadline
  readonly alarms: { rehydrate(): void };
  readonly internals: SessionComponents;                  // integration-test introspection only
}
```

- **`durable-object.ts`: 1716 → 111 lines.** The DO is now a pure
Cloudflare adapter: it initializes the runtime once per activation and
forwards the five platform callbacks. All 24 lazy getters, 23 memo
fields, the routes table, the socket adapters, and every domain method
are gone from it.
- The domain logic that previously stayed behind is extracted along real
ownership lines:
- `SessionConnectionAuthenticator` (`connection-authenticator.ts`) —
sandbox WS upgrade guards (the post-auth re-check block), client
subscribe + token TTL, snapshot handoff, post-hibernation identity
recovery;
- `SessionSnapshotReader` (`snapshot-reader.ts`) — the session read
model (snapshot route + subscribe payload, per-repo git state,
environment-name enrichment);
- `SessionAccessReader` (`sandbox-access-reader.ts`) — sandbox access
credential decryption/authorization;
- `SessionTitleService` (`title-service.ts`) — title
normalization/persistence/broadcast/index-sync.
- Repositories, services, and handlers are **local to the factory**.
Production code cannot reach them: the DO touches only `log`, `server`,
and `alarms`. `internals` is not the graph — it is the **enumerated set
of integration-test seams** (8 fields: 7 live collaborators to spy on,
plus the substitutable SCM provider cell), each with a test that reaches
it inside a `runInDurableObject` callback (the pre-existing idiom —
those tests run against a live DO whose graph the init request already
built, so their seams are necessarily late-bound). Adding a seam means
adding both the field and its consuming test.
- The boundary is **governed, not conventional**: a scoped
`no-restricted-imports` rule allows only `session/durable-object.ts`
(and tests) to import the composition root — red-verified by tripping it
from a sibling module. Together with the DO exposing nothing to
reference, the planned Phase 5 back-edge rule becomes trivial.
- **One session-scoped logger with live context**: the factory creates a
single logger whose `session_id` is injected per emit through a latched
resolver (`session-logger.ts` + `createLatchedPublicSessionIdResolver`).
On a first-ever activation the id upgrades from the DO id to the public
session name the moment `/internal/init` writes the row — for every
component, however early it captured the logger. (Previously the id was
frozen at logger creation; on genesis activations that meant the DO id
for the whole activation.)

## The two provider factories stay deferred — deliberately

`createSandboxProviderFromEnv` and `createSourceControlProviderFromEnv`
**throw on reachable configurations** (missing provider credentials;
invalid `SCM_PROVIDER`; GitLab without a token; Bitbucket). Constructing
them eagerly would turn `/internal/init` — which works on such
deployments today and is pinned by the wiring suite — into a 500 on
every request. The deferral is confined to documented cells at the root:

- the sandbox provider, behind `createDeferredSandboxProvider`
(getter-shape adapter over `once()`, so the lifecycle manager's
signature and its 84 test construction sites don't change, and
optional-method presence probes stay truthful);
- the SCM provider, behind `once()` in a **local cell**; consumer
closures capture a stable local binding (no temporal dead zone, no read
through the returned record). `internals.sourceControlProvider` is an
accessor pair over that cell — the getter for introspection, the setter
as the live-DO integration seam;
- `resolveScmProviderFromEnv` / `resolveSandboxBackendName` name
resolution (also throwing) resolved per use.

`test/integration/session-components.test.ts` pins the invariant: the
full graph assembles under a deployment where both factories and both
name resolutions throw.

## Initialization

`ensureInitialized()` builds into a local and **publishes last** — a
throw during schema init or graph construction leaves the activation
uninitialized, so the next event retries instead of dereferencing an
undefined runtime. It also emits a one-line `do.init` event with the
initialization duration; the dispatcher's per-request `init_ms` now
reads ~0 because initialization happens before dispatch (the cold-start
cost moved to `do.init` — deliberate observability change).

`SessionServerDeps.ensureInitialized` is kept and threaded through as a
declared factory input, so hibernation-restored callbacks self-heal with
exactly the same contract (and `server.test.ts` stays untouched).

## Frozen-at-construction values became per-use thunks

Two values used to be read at first getter touch — after
`/internal/init` commits the row — and would have been frozen too early
by eager construction:

- `executionTimeoutMs` (honors `sandbox_settings`):
`SessionMessageQueue` and the alarm handler take `getExecutionTimeoutMs:
() => number`, resolved when a deadline is armed. Pinned by a
two-dispatch freshness test.
- the lifecycle manager's log-correlation id: `config.sessionId` →
`config.getSessionId` thunk with a memoized derivation, sharing the same
latched resolver as the session logger. Pinned by a log-context
transition test.

All three pins are **mutation-verified**: the intended regression
mutants (first-use freeze, latch-the-fallback, constructor-time capture)
each fail their test.

## Behavior notes (deliberate, small)

- Unsupported `SANDBOX_PROVIDER`: the lifecycle manager now constructs;
the same `Unsupported SANDBOX_PROVIDER` error surfaces at the spawn that
needs the provider (absorbed at the background-task boundary). Strictly
more requests work on such a deployment.
- Log lines from all session components now carry a live `session_id`
(upgrade mid-activation on genesis) — an observability improvement, not
a wire-format change.
- `do.request`'s `init_ms` reads ~0; `do.init` supersedes it (see
Initialization).
- `UserEnvResolver`'s `log` dep is a plain `Logger`; the
call-time-logger contract test is deleted with the contract it pinned.

## Tests

- **Integration: 81 files / 999 tests green**, including the
eviction/hibernation suite and the WS auth-race suite, against the
runtime shape.
- Unit: 206 files / 3184 tests, including new suites for the session
logger wrapper, the latched resolver, the deferred-provider adapter, and
the title service.
- Integration tests that pierced DO privates are consolidated behind one
accessor (`componentsOf` in `session-do-access.ts`, honestly documented:
this directory is never typechecked, so the casts are hand-synced).

## What this unblocks

Phase 4 (collapse the five socket-port abstractions) and Phase 5 (the
lint rule pinning that nothing the factory builds references the DO —
now trivially true, since the DO exposes nothing to reference). The
ColeMurray#1589 spawn-generation fence design now has its home in
`SessionConnectionAuthenticator`.

Part of the SessionDO decomposition plan (Wave A ColeMurray#1577/ColeMurray#1578/ColeMurray#1579,
Phase 1.5 ColeMurray#1583, Wave B ColeMurray#1585/ColeMurray#1586/ColeMurray#1587).

---

## Review history

**Round 1 (pre-open, 6-lens adversarial pass):** 4 findings confirmed
and fixed — per-log-line SQL read in the manager's log derivation (→
latched resolver), missing pins for the per-use thunk contracts (→
mutation-verified tests), stale seam doc comment.

**Round 2 (teammate review, Request Changes at `d1d6c385`):** all
findings accepted and addressed at `9c6c01a4`:
1. *Init can poison an activation* → build-into-local, publish-last
(above).
2. *Mutable back-edge / TDZ in the SCM thunk* → local cell + accessor
pair; consumer closures never read through the returned record;
`SessionRuntime` itself is immutable. One qualification kept from the
discussion: pre-construction injection through factory inputs cannot
serve the live-DO integration idiom (tests inject **after** the init
request they triggered has built the graph), so one explicit, documented
late-bound seam remains — on `internals`, not on the runtime.
3. *Service-locator surface / no ownership boundaries* → the narrow
`SessionRuntime` above, one step further than the sketch: since the
routes table and socket wiring also moved into the factory, the adapter
needs only `log`/`server`/`alarms` rather than per-capability members.
Connection auth, snapshot assembly, and sandbox access are extracted as
owning classes. At `d5e791b7`, `SessionComponents` was additionally
shrunk from the full graph (~41 entries) to the 8 test-reached seams,
and the only-the-adapter-imports-the-root lint rule landed.
4. *(Non-blocking) genesis logger pins the DO id* → fixed for all
components via the per-emit `session_id` injection, not just the
lifecycle manager.
…ray#1602)

## What

Follow-up to ColeMurray#1594, by team decision: the two provider factories now
construct **eagerly at graph build**, deleting the deferral machinery
the composition root carried (`createDeferredSandboxProvider`, `once()`,
`tryResolveSandboxBackendName`, and the thunk parameters that existed
only to defer throwing name resolution). Net −151 lines.

## Behavior change — deliberate

On a misconfigured deployment (missing sandbox-provider credentials,
unsupported `SANDBOX_PROVIDER`, invalid `SCM_PROVIDER`, GitLab without a
token, Bitbucket), every session request now fails at
`ensureInitialized()` — including `/internal/init` — instead of init
succeeding and the error surfacing later as an absorbed
`background_task.failed` at the first spawn or PR operation.

Rationale: the environment must not run misconfigured; deployment-time
validation is the gate for configuration, not runtime degradation.
Failing at graph build also fails **before any DO session state is
written**, so a misconfigured deployment leaves no partially-initialized
DO state behind (the D1 index row from session-create is cleaned by the
draft-expiry sweep, as with any init failure).

What this reverses: the "init succeeds on a provider-less deployment"
posture pinned since ColeMurray#1583. What it keeps: ColeMurray#1583's actual bug class
stays fixed — `ensureInitialized` still builds into a local and
publishes last, so a throw leaves the activation uninitialized and
retryable, and **runtime** spawn failures (provider API down, quota) are
still absorbed by the background-task boundary without failing init
(test retained, reworded).

## Simplifications

- `components.ts`: both providers and both name resolutions construct
eagerly at the root; the getter-shape `createDeferredSandboxProvider`
adapter, `once()`, and `tryResolveSandboxBackendName` are deleted.
- The `SessionComponents.sourceControlProvider` seam is instance-typed
(`SourceControlProvider`, was `() => SourceControlProvider`) — still an
accessor pair over a local cell, so live-DO integration tests substitute
stubs exactly as before (5 injection sites simplified).
- `SessionMessageQueue` regains its pre-ColeMurray#1594 parameter: `scmProvider:
SourceControlProviderName` as a value (the thunk existed only for
deferral).
- `SessionConnectionAuthenticator` takes `scmProviderName` as a value
for the same reason.
- Unchanged: `getExecutionTimeoutMs` and `getSessionId` stay thunks —
they defer for **freshness** (honoring `sandbox_settings` persisted
after construction; the public-session-id latch), not for misconfig
tolerance.

## Tests

- `test/integration/session-components.test.ts` inverted: it was the
eagerness-tolerance pin ("the graph builds without constructing either
provider"); it now pins fail-fast — a healthy env builds, and an
unsupported `SANDBOX_PROVIDER`, missing modal credentials, or an invalid
`SCM_PROVIDER` each throw at graph build naming the bad configuration.
- `src/session/components.test.ts` (the deferred-adapter unit suite) is
deleted with the adapter.
- "keeps session init succeeding when the warm spawn cannot build its
sandbox provider" → reworded to the invariant that survives: **runtime**
warm-spawn rejections are absorbed and init still succeeds.
- Unit 3180 / integration 1002 (81 files) all green; typecheck, eslint
(including the only-the-adapter-imports-the-root rule), and prettier
green. The integration env models a correctly configured deployment
(modal creds + default GitHub SCM), so eager construction is exercised
by every DO test.


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

* **Bug Fixes**
* Invalid or incomplete provider configurations are now detected during
session startup instead of later during sandbox or pull request
operations.
* Provider initialization errors are surfaced earlier, making session
startup behavior more predictable.
* Runtime sandbox failures are handled separately from configuration
errors.

* **Tests**
* Expanded integration coverage for fail-fast startup validation and
provider configuration errors.
* Updated session and pull request tests to reflect direct provider
configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
ColeMurray#1603)

## What

Phase 5 of the SessionDO decomposition plan, plus the small root
deduplications that ColeMurray#1594/ColeMurray#1602 exposed. Zero behavior change (one log
field dropped, see below).

### The back-edge boundary rule (Phase 5)

The plan's original Phase 5 rule — "no `this.` inside arguments passed
to a constructor in `durable-object.ts`" — became vacuous after ColeMurray#1594:
the adapter passes no collaborators anywhere. What remains meaningful is
the module-level back edge, and it's now governed:

- **Only `src/index.ts` may import `session/durable-object`** (it
exports the DO class to the runtime). Nothing the factory builds can
hold a reference back to the DO, because nothing else can even name its
module.
- This completes the pair with ColeMurray#1594's rule that only the adapter may
import the composition root.

Both bans live in one `no-restricted-imports` block (base rule, so the
repo-wide `@typescript-eslint/no-restricted-imports` shared-auth config
still stacks). Flat-config replaces per-rule configs for overlapping
file sets rather than merging them, so the general block carries both
bans and `src/index.ts` re-declares the composition-root ban it is still
subject to — the structure is commented in `eslint.config.js`.

Red-verified in all three directions: a session module importing the
adapter trips; `title-service.ts` importing the root still trips after
the restructure; `index.ts` importing the root trips.

### Root deduplication

`createSessionRuntime` constructed several identical things repeatedly;
each is now one shared local:

- `SessionIndexStore` — was constructed 4× (`db ? new
SessionIndexStore(db) : null` at the terminal-message projection, status
service, title service, message queue)
- `SessionPullRequestStore` — was constructed 2× (PR refresh, PR
creation)
- the `resolveSessionRepoId` closure — was declared 3× (user-env
resolver, OpenAI + xAI token refresh)
- the terminal-message projection closure — was declared 2× (message
queue, sandbox event processor)

### `init_ms` removed from `do.request`

Since ColeMurray#1594, initialization happens before dispatch (the adapter builds
the runtime on first event), so the dispatcher's per-request `init_ms`
always reads ~0 — the one-line `do.init` event carries the real
cold-start duration. The dead field and its clock read are removed;
`duration_ms`/`handler_ms` are unchanged. (The dispatcher still calls
`ensureInitialized()` — removing that dep is Phase 4's churn, where the
server stack is already being touched.)

## Tests

3180 unit / 1002 integration green; typecheck, eslint, prettier green.
`server.test.ts` touched only to drop the `init_ms` assertion and its
fake-clock sample.

Part of the SessionDO decomposition plan (ColeMurray#1577/ColeMurray#1578/ColeMurray#1579, ColeMurray#1583,
ColeMurray#1585/ColeMurray#1586/ColeMurray#1587, ColeMurray#1594, ColeMurray#1602). Phase 4 (socket-port collapse) follows
on top of this.


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

* **Refactor**
* Improved session runtime efficiency by reusing shared session and
pull-request services.
* Centralized terminal-message recording for more consistent session
behavior.

* **Bug Fixes**
* Simplified request timing information by removing initialization-time
metrics from request logs.

* **Tests**
* Updated session server tests to reflect the revised request timing
data.
* Added validation for session import boundaries to help prevent
configuration-related issues.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
ColeMurray#1604)

## What

Phase 4 of the SessionDO decomposition — the socket-layer collapse.
Stacked on ColeMurray#1603 (retarget to `main` after it merges). Net ~−230 lines
and one fewer abstraction layer on every message the session sends.

## The finding that reshaped the phase

The plan (§Phase 4) prescribed retiring `SocketRegistry`,
`SandboxBroadcaster`, and the lifecycle manager's `WebSocketManager` "in
favour of `SessionConnections`". Auditing the post-ColeMurray#1594 code inverted
that: **`SessionConnections` was itself the parallel abstraction.** Of
its six methods, four (`registerBrowser`, `registerSandbox`,
`listParticipants`, `disconnectSandbox`) had **zero production
consumers**; `InMemorySessionConnections` — the "working double" — was
consumed only by its own test file; and the one non-messenger consumer
(`SessionConnectionAuthenticator`) bypassed the interface entirely,
depending on the concrete class for `createUpgradeSockets()`, which
isn't even on the port.

So the collapse goes the other way:

- **`SessionMessengerImpl` now implements delivery directly over
`SessionWebSocketManager`** — same interface (`broadcast` /
`sendToSandbox`), same semantics byte-for-byte (including
`SandboxDeliveryUnavailableError`, which moves to `messenger.ts`;
`diffs/service.ts` catch repointed). The 13 messenger consumers see no
change.
- **Deleted**: `connections.ts`,
`durable-object-session-connections.ts`, both their test files, and the
dead 4-method surface.
- `createUpgradeSockets()` moves to `SessionWebSocketManager` (socket
creation lives with the socket registry); the authenticator's concrete
`connections` dep is gone.
- The hibernation ping/pong auto-response setup moves to the composition
root (platform-global wiring; also keeps `SessionWebSocketManagerImpl`
constructible in the node unit environment, where
`WebSocketRequestResponsePair` doesn't exist).
- `projectConnectedParticipants` moves to its only consumer
(`presence-service.ts`) and is retyped against the shared
`ParticipantPresence` (field-identical); the private
`ConnectedParticipant` type dies.

## SandboxBroadcaster: tightened and satisfied by the messenger

`SandboxBroadcaster.broadcast(message: object)` → `broadcast(message:
ServerMessage)`. Every one of the lifecycle manager's ~25 broadcast
payloads already typechecks against the shared union, so the root now
passes the **messenger itself** as the broadcaster and the casting
adapter literal is deleted. The interface (and the manager's
constructor) is otherwise untouched — deliberately: `manager.test.ts`
has 83 direct construction sites, and the interface is a legitimately
narrow consumer-owned port.

## ensureInitialized unthreaded from the server stack

`SessionServerDeps.ensureInitialized` and
`SessionHttpDispatcherDeps.ensureInitialized` are removed, along with
the calls at every entry point, and `SessionPlatform` drops the field.
Since ColeMurray#1594, the adapter initializes the runtime before any server entry
point is reachable — including callbacks delivered to a
hibernation-restored instance, which reconstruct the runtime through the
private `runtime` getter (and `alarm()`'s explicit call). The in-stack
calls were structurally unreachable-before-initialized. The
eviction/hibernation integration suite passes unchanged, which is
precisely the scenario this threading existed for.

## What deliberately stays, and why

- **`SocketRegistry` + the `ports.ts` interfaces** — the server stack
(router, disconnect handler) is generic over `<Connection, Client>` and
needs connection-addressed operations (classify/reply-to/close *this*
socket) that a connection-anonymous delivery port cannot express; that
genericity is what lets `server.test.ts` run the full routing/disconnect
policy on plain string connections. `ports.ts` now carries a header
stating this rationale.
- **The lifecycle manager's `WebSocketManager` port + its 8-line
adapter** — consumer-owned narrow port; retiring it is an 83-test-site
churn for zero production delta.
- **`message-queue.ts` / `sandbox-events.ts` stay on
`SessionWebSocketManager`** — the plan's "point them at the port"
doesn't survive the actual flows: prompt dispatch is capture-claim-send
(the socket captured before the claim guarantees the send goes to the
sandbox that existed at claim time — an identity property worth keeping
after ColeMurray#1586), and `pushBranchToRemote`'s no-socket branch is a
*semantic* outcome ("assume pushed manually"), not a delivery failure. A
connection-anonymous port can't express either without changing edge
behavior. The messenger's docstring states this boundary.

## Tests

- Unit 3170 (203 files) / integration 1002 (81 files) — all green;
typecheck, eslint (boundary rules included), prettier green.
- `messenger.test.ts` rewritten against a registry fake, porting the
live coverage from the deleted suites: authenticated-only fan-out,
sandbox delivery, no-sandbox rejection, and send-failure rejection.
- `server.test.ts`: `ensureInitialized` fake and assertions removed;
behavior assertions unchanged.

Part of the SessionDO decomposition plan (ColeMurray#1594, ColeMurray#1602, ColeMurray#1603). With
this, the decomposition's phases are complete.


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

- **Improvements**
  - Improved session WebSocket connection handling and message delivery.
- Added automatic ping/pong responses to support reliable connections
during hibernation.
- Improved sandbox message delivery with clearer handling when a sandbox
is unavailable.
- Improved participant presence tracking across multiple active
connections.
- Simplified session initialization for more consistent request,
message, and disconnect handling.
- Improved connection setup and routing for more reliable session
communication.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
… write (ColeMurray#1606)

## What

Closes ColeMurray#1589 with the issue's recommended fix: the **persist-first,
two-phase spawn write**.

`doSpawn()`, its prebuilt-image retry, and `restoreFromSnapshot()` all
followed the same ordering: set in-memory flags, `await hashToken(...)`
— a non-storage await, so the DO input gate admits other events — and
only then persist the new identity. During that await the OLD sandbox
row was still the persisted truth, so a slow bridge from a
reconnect-allowed sandbox (e.g. `failed`, deliberately reconnect-allowed
so slow boots self-heal) could authenticate against the old row and pass
every ColeMurray#1586 guard: both the pre-auth read and the post-auth re-read saw
the same stale row. Comparing two persisted snapshots cannot detect a
transition that has started but not persisted.

## The fix

All three sites now write in two phases:

1. **Phase 1 — synchronous, before the first non-storage await**:
`updateSandboxForSpawn` persists the replacement identity (`status:
"spawning"`, new `modal_sandbox_id`) with `auth_token_hash: ""` — no
token can match it. DO SQLite writes are synchronous, so this is visible
before the input gate can open. A stale bridge arriving in what used to
be the window now fails the sandbox-ID check (403) or the token check
(401), or the ColeMurray#1586 credentials-consistency re-check (403).
2. **Phase 2 — after hashing**: a new narrow
`SandboxStorage.updateSandboxAuthTokenHash()` publishes the real hash.
The hash-less gap is unobservable: the provider has not been invoked
yet, so no bridge holding the new identity can exist.

Accepted caveat, per the issue: a spawn failure after phase 1 leaves the
old credentials invalidated — replacement only starts when the old
sandbox is being discarded, and failure paths already mark `failed` and
re-spawn.

## Red-first race test

Per the issue's requirement, the interleaving is reproduced before being
fixed: `manager.test.ts` mocks `hashToken` with a pass-through gate
(default open — the other 118 tests run unchanged against the real
implementation), holds the next call open, starts a spawn, and asserts
the **mid-window persisted row** — exactly what a stale bridge's
admission read sees. On the previous code both cases fail with the old
identity still visible (`expected 'sb-old' not to be 'sb-old'`); with
the fix, the row already carries the new `modal_sandbox_id`, an emptied
hash, and `spawning`. After release, the tests pin that phase 2
published a real hash and that the reservation write strictly precedes
it. Both the fresh-spawn and snapshot-restore paths are covered; the
image-retry path uses the same two-phase ordering (its existing
`updateSandboxForSpawn`-call-count assertions are unchanged).

## Notes

- Fix option 2 from the issue (the spawn-generation fence in the
admission path) is superseded — revisit only if two-phase persistence
proves insufficient.
- `SandboxStorage` gains one method; implementers are the
composition-root adapter and the shared test mock.

Battery: 3172 unit (203 files) / 1002 integration (81 files), typecheck,
eslint, prettier — green.


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

* **Security Improvements**
* Improved sandbox startup and snapshot restore authentication handling.
* Sandbox credentials are invalidated before replacement credentials are
generated, preventing stale authentication attempts during transitions.
* New authentication hashes are persisted only after generation
completes and are tied to the active sandbox identity.

* **Bug Fixes**
* Improved protection against authentication race conditions during
sandbox creation and snapshot restoration.
* Prevented superseded sandbox launches from publishing outdated
credentials or incorrectly marking the newer launch as failed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…gest closure bags (ColeMurray#1608)

## What

First PR of the deps-style normalization campaign (follow-through on the
ColeMurray#1594ColeMurray#1604 decomposition): replace the composition root's three biggest
closure-bag literals with composition classes, per the house deps
standard from the ColeMurray#1045-series (pass collaborators directly with full
types; give a closure group that shares collaborators a named class).

Behavior-preserving — no port changes, no call-flow changes.

## Changes

- **`DurableObjectSandboxStorage`** (new
`session/sandbox-lifecycle-adapters.ts`) implements the lifecycle
manager's `SandboxStorage` port over its four real collaborators:
`SandboxRepository`, `SessionCoreRepository`, `UserEnvResolver`, and the
secrets encryption key. Replaces the 28-property literal in the root.
The encrypt-before-store rule (code-server/VNC/ttyd secrets), previously
copy-pasted three times inline, is one private `encryptIfConfigured`
method.
- **`LifecycleSocketAdapter`** (same file) implements the manager's
`WebSocketManager` port over `SessionWebSocketManager` — the name
translation and the no-socket send branch get a typed home instead of a
literal.
- **`SessionClientCommandFacade`** (new
`session/client-command-facade.ts`) implements the message router's
`SessionClientCommands<WebSocket, ClientInfo>` port with the four
services as constructor deps. The port itself stays generic — that
genericity is what lets the server stack unit-test over string
connections, so the facade is the production binding, not a port
rewrite. The router's client-message type aliases are now exported (they
are referenced by the exported port, so naming them outside the module
was already implied).

Net: 39 function-valued props removed from `components.ts`; the root now
constructs objects in these three spots instead of authoring behavior
inline.

## Tests

New `sandbox-lifecycle-adapters.test.ts` covers the pieces with real
logic, which previously lived untested inside the root literal: the
encrypt-when-configured branch (round-trips via `decryptToken`), the
plaintext-passthrough branch, the repository-shape defaults
(`baseBranch` → `"main"`, missing row → `baseSha: null`), the
`setLastSpawnError` → `updateSandboxSpawnError` rename, and both
`sendToSandbox` branches. Pure forwards stay covered through the manager
and server suites.

## Queue context

Next in the campaign (separate PRs): handler deps-bags → classes
(normalizing the 7-factory/5-class split), vestigial thunk removal
(`getLogger: () => log` first), and the `test/integration` typecheck
spike.


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

- **Refactor**
- Improved session command handling for prompts, execution controls,
typing indicators, presence, subscriptions, and history.
- Improved sandbox lifecycle and WebSocket handling for more consistent
session connectivity.
- **Security**
- Sandbox access credentials can now be encrypted when configured, while
retaining compatibility with existing setups.
- **Tests**
- Added coverage for credential storage, sandbox startup errors,
repository behavior, and WebSocket communication.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…lve the storage middle-man (ColeMurray#1609)

## What

Campaign item 2, combining two agreed decisions: **the secrets
encryption key is required** (it always was operationally — Terraform
declares it with no default — but the code treated it as optional and
silently fell back to storing plaintext), and **the storage middle-man
from ColeMurray#1608 is dissolved** (its ~25 one-line pass-throughs were the smell
that prompted the design discussion).

## Encryption key is required

- New `requireRepoSecretsEncryptionKey(env)`: the session graph throws
at construction when the key is absent (the ColeMurray#1602 eager posture — a
misconfigured deployment fails every request at initialization instead
of running degraded), and the five MCP-server routes validate the same
way.
- Every plaintext-**write** fallback is deleted: the sandbox
access-secret stores, `McpServerStore`'s keyless branch, and
`UserEnvResolver`'s "skip secret loading" branch.
`isManagedSecretsConfigured` reduces to `Boolean(db)`.
- Plaintext-**read** fallbacks stay: pre-encryption legacy rows still
decrypt-or-degrade exactly as before (`McpServerStore`'s catch fallback,
access values resolving to null on decrypt failure).
- The integration environment already provides a test key in its
miniflare bindings, so no test-infra changes were needed.

## Encryption is owned by persistence; the middle-man is gone

- `SandboxRepository` takes the key at construction and encrypts
code-server/VNC/ttyd secrets inside its write methods — the same pattern
the D1 stores already use. No caller can persist an access secret in the
clear, structurally.
- The manager's conflated port is **split into two roles** — the root
cause behind both the ColeMurray#1608 forwarding layer and an interim inheritance
design. `SandboxStorage` shrinks to the sandbox-row contract, which
`SandboxRepository` now satisfies **structurally** (no adapter, no
subclass, and no manager-port import in the repository — the structural
check happens at the composition boundary). The three session-context
reads become their own `SessionContextReader` port, implemented by a
small `LifecycleSessionContext` facade over `SessionCoreRepository` +
`UserEnvResolver` — an honest adapter: it spans two collaborators and
owns the repository-shape defaults. `DurableObjectSandboxStorage` is
deleted.
- The shared test mock already implements both ports, so the manager's
test harness changes are mechanical: the same fake is passed for both
parameters at every constructor site.
- `updateSandboxSpawnError` is renamed `setLastSpawnError` to match the
port vocabulary, removing the last name translation.

## Tests

Encryption round-trips (via `decryptToken`) now live in
`sandbox-repository.test.ts` with the logic; the adapter tests pin the
context mapping and the inheritance wiring ("sandbox writes hit SQL with
no forwarding layer"). Deleted-behavior tests are deleted with their
behavior: the keyless verbatim-read test, the resolver's
skip-secret-loading test, and ColeMurray#1608's synchronous-keyless-persist test
(that branch no longer exists — with the key required, every secret
write takes the same WebCrypto await it always took on real
deployments). `McpServerStore` tests construct keyed; their
plaintext-seeded rows now exercise the legacy-read fallback, which is
exactly what such rows are.

## Behavior change (intended)

A deployment without `REPO_SECRETS_ENCRYPTION_KEY` now fails loudly at
session initialization and on MCP routes, instead of silently persisting
secrets unencrypted. Valid deployments are unaffected.


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

* **Security**
* Repository secrets encryption is now required for control-plane
operations.
* Sandbox passwords, tokens, credentials, and stored environment secrets
are encrypted before persistence.
* Encryption keys are strictly validated for required format and length.

* **Bug Fixes**
  * Improved handling of unavailable or empty stored secrets.
  * Reduced unnecessary decryption errors for empty credentials.
  * Improved sandbox error reporting.

* **Refactor**
* Streamlined sandbox lifecycle and session-context handling for more
consistent behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- move the six Python CI jobs into a dedicated `CI (Python)` workflow
- keep the seven Node.js/TypeScript jobs in `CI (TypeScript)`
- trigger each workflow only for its package and root-tooling dependency
surface
- preserve the Markdown-only exclusions added in ColeMurray#1590

## Motivation

The main CI workflow currently runs both ecosystems for every code
change. This split prevents Python-only changes from allocating
TypeScript runners and TypeScript-only changes from allocating Python
runners, while preserving all existing job commands and dependencies.

This is the ecosystem-level step before introducing narrower
package-aware filtering in follow-up PRs.

## Validation

- `npx prettier --check .github/workflows/ci.yml
.github/workflows/ci-python.yml`
- parsed both workflows and verified all 13 original job definitions
remain present
- `git diff --check`

`actionlint` and Go were unavailable in the local environment. The
repository-wide `npm run format:check` also reports a pre-existing
formatting issue in `.opencode/package.json`; both changed workflow
files pass their targeted formatting check.

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

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

* **Chores**
* Added dedicated continuous integration checks for Python linting,
formatting, type checking, and tests.
  * Updated TypeScript validation to run through a dedicated workflow.
* Refined workflow triggers to focus on relevant code and configuration
changes, excluding documentation-only updates.
* Expanded validation coverage for runtime, deployment, and
infrastructure changes.
* Added concurrency controls to cancel outdated runs and strengthened
workflow security settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format
Init
Validate
Tests

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

@jasoncuriano
jasoncuriano merged commit 192c167 into main Aug 26, 2026
19 checks passed
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.

7 participants