From c19bc0c2ef71dd029e53c310f8f9f6556705e450 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Sat, 26 Sep 2026 22:15:22 -0400 Subject: [PATCH 1/5] docs(sessions): spec the per-session sandbox and browser version record (D-31, schema v4) Spec first for #19: sessions.sandboxed and sessions.browser_version (migration 0004, compatible), written once the browser has launched (latest launch wins), served as SessionSummary.browser for live and closed sessions alike; absent means not recorded, never not sandboxed. Dashboard Details shows sandboxed / not sandboxed / not launched / not recorded. MCP tool output unchanged. --- specs/00-decisions.md | 19 ++++++++++++++++++- specs/02-mcp-and-tools.md | 2 +- specs/03-admin-backend.md | 11 ++++++++--- specs/04-admin-frontend.md | 2 +- specs/09-testing.md | 1 + specs/11-stealth.md | 4 +++- 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/specs/00-decisions.md b/specs/00-decisions.md index e505b3f..4f0fb6f 100644 --- a/specs/00-decisions.md +++ b/specs/00-decisions.md @@ -560,7 +560,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA - An agent may still request `launch_options.chromiumSandbox: true` (it only strengthens the posture); it makes the sandbox a requirement for that session. `chromiumSandbox: false` stays refused (D-12, spec 11 §4). - A launch failure caused by the sandbox is `SANDBOX_UNAVAILABLE` in every mode, never `INTERNAL_ERROR`. When the failure text is not one BrowserHive recognises, it is proven the way the probe proves it: the same browser starting without the sandbox (Edge's SUID-helper message on Ubuntu was the first such case the cross-OS job found; it is now recognised too). -**Consequences.** On macOS, Windows, most Linux hosts and with Google Chrome on Ubuntu, sessions now run sandboxed without configuration. On Ubuntu with the bundled browser the first session of a process pays one failed launch (about 0.3 s) and runs unsandboxed, as before; `doctor --printApparmorProfile` prints (never installs) a profile that fixes it. Page-visible signals are identical with and without the sandbox (measured on all three OSes). `doctor` reports the fallback with its fix but as ✓, not a warning: sessions still launch, and a `doctor` that started exiting 2 would break healthchecks that passed before; under `on` the same verdict is a failure. +**Consequences.** On macOS, Windows, most Linux hosts and with Google Chrome on Ubuntu, sessions now run sandboxed without configuration. On Ubuntu with the bundled browser the first session of a process pays one failed launch (about 0.3 s) and runs unsandboxed, as before; `doctor --printApparmorProfile` prints (never installs) a profile that fixes it. Page-visible signals are identical with and without the sandbox (measured on all three OSes). Which way each session actually ran is recorded at launch and kept with the session (D-31). `doctor` reports the fallback with its fix but as ✓, not a warning: sessions still launch, and a `doctor` that started exiting 2 would break healthchecks that passed before; under `on` the same verdict is a failure. **Alternatives considered.** Default `on`: rejected, since it would refuse to start on every Ubuntu 23.10+ host with the bundled browser, a new failure for operators who asked for nothing. Default `off`: kept only until the cross-OS measurement showed `auto` never breaks a launch. Probing every browser at boot under `auto`: rejected, since it adds a browser launch to every start (and to every test boot) for information the first real launch provides. Installing an AppArmor profile or a setuid helper automatically: rejected, since it needs root and changes the host's security policy (plan §10). @@ -612,3 +612,20 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Consequences.** With no configuration, Claude Code and Gemini CLI over stdio and Codex, OpenCode, Cursor, Claude Code and others over HTTP are recognised; everything else is `unknown` rather than an empty cell. One header, environment variable or query parameter names any other harness. Per-harness notification routing and per-harness template visibility can read `harness` from session and tool-call event payloads without a query. Metric series grow by at most the size of the known table. **Alternatives considered.** *Enforcing policy on the reported harness*: rejected; any client can claim any name, so a harness allowlist would be a typo guard presented as a control. *A closed zod enum*: rejected; a new harness ships every month and an unknown value would become a 400. *Inferring the model* (sampling, inherited `ANTHROPIC_MODEL`, a tool argument): rejected; sampling names the model the client picked for that request, is deprecated in `2026-07-28` and would prompt the user, and the others are guesses. *Process inspection of the stdio parent*: rejected; fragile across platforms and a poor look for a local-first tool. *A `/mcp/` path*: deferred; the query form works with no routing change. *Registering the identity variables as config keys*: rejected; they would appear in `--help` and `config show` as server settings, which they are not. + +## D-31 A session's sandbox state and browser version are recorded at launch + +**Status:** Accepted + +**Context.** Since D-27 a session's summary says whether its browser runs inside Chromium's sandbox, and with which real browser version, but the value was read from the live browser handle only: once the session closed it was gone, and history could no longer answer "did this session run sandboxed?". Under `sandbox=auto` the answer depends on the browser and the host (Ubuntu with the bundled browser falls back, Google Chrome on the same machine does not), so it cannot be inferred from the configuration afterwards. + +**Decision.** +- Schema v4 (`0004-session-browser`, `compatible: true`) adds two nullable columns: `sessions.sandboxed` (`INTEGER CHECK IN (0,1)`) and `sessions.browser_version` (`TEXT`). Nothing is backfilled: a guess would be worse than no answer. +- The session aggregate keeps the launch facts it attached (`browserInfo`: the verdict the sandbox policy applied and `Browser.version()`), including after teardown. They are written with the `session.updated` patch once the browser has launched (first when the session becomes live) and never cleared. If a session's browser is launched again, the latest launch wins, since that is what the session ran with last; today no path relaunches a session's browser. +- `SessionSummary.browser` keeps its shape (`{version, sandboxed}`, optional). Every builder applies the same order: the live handle, then the recorded launch (in memory or from the row, when `sandboxed` is not NULL), else absent. A persistent context has no `Browser` object, so its version stays `null` while its verdict is recorded. +- Absent means "not recorded" (sessions from before v4, sessions whose browser never launched, drivers that report no launch facts). No surface shows it as "no" or "not sandboxed"; the dashboard distinguishes "not launched" where the state says so. +- The MCP tool surface is unchanged (D-12): the record is on the admin API and the dashboard only. + +**Consequences.** Closed sessions keep showing whether they ran sandboxed and with which browser version, in the API, over WS and in the dashboard. Older readers still open the database (the migration is additive). History from before the upgrade honestly reads "not recorded". + +**Alternatives considered.** *Inferring the verdict for old rows from the channel and the host*: rejected; the policy's verdict depends on the executable, the host and the mode at the time, none of which is stored. *First launch wins*: rejected in favour of the latest launch, which is what the session actually ran with last. *A new `sandbox: 'sandboxed' | 'unsandboxed' | 'unknown'` wire field*: rejected; the existing optional `browser` already expresses "not recorded" by its absence, and a second field would duplicate it. diff --git a/specs/02-mcp-and-tools.md b/specs/02-mcp-and-tools.md index 7e80825..b76c89d 100644 --- a/specs/02-mcp-and-tools.md +++ b/specs/02-mcp-and-tools.md @@ -363,7 +363,7 @@ await server.stop({ deadlineMs?: 20_000 }); // idempotent; unwinds even after a ## Design notes - Typed codes (`NAVIGATION_TIMEOUT`, `WAIT_TIMEOUT`, `DOWNLOAD_FAILED`, `UPLOAD_FAILED`, `SCRIPT_ERROR`, `NAVIGATION_FAILED`) are used instead of `INTERNAL_ERROR` for failures an agent can act on. `INTERNAL_ERROR` is never a documented code of any tool, so introducing a typed code for a failure mode is additive. Interaction tools report actionability failures as `ELEMENT_NOT_ACTIONABLE` only. -- `launch_session` gains keys and error codes only additively (`proxy_label`, `driver`, `BROWSER_NOT_INSTALLED`, `SANDBOX_UNAVAILABLE`; D-13, D-18, D-27). Its input schema and `SessionMetadata` output did not change for the sandbox: the verdict is on the HTTP `SessionSummary` (03 §4.2) and `/system`, not in the tool output. +- `launch_session` gains keys and error codes only additively (`proxy_label`, `driver`, `BROWSER_NOT_INSTALLED`, `SANDBOX_UNAVAILABLE`; D-13, D-18, D-27). Its input schema and `SessionMetadata` output did not change for the sandbox: the verdict is on the HTTP `SessionSummary` (03 §4.2) and `/system`, not in the tool output; the per-session record of it (D-31) is likewise admin-only, and `list_sessions` is unchanged. - `launch_options.chromiumSandbox`: `false` is refused (`UNSAFE_LAUNCH_ARG`, spec 11 §4); `true` is accepted in every `sandbox` mode because it only strengthens the posture, and makes the sandbox a requirement for that session (a host that cannot give it answers `SANDBOX_UNAVAILABLE`, never `INTERNAL_ERROR`). - `list_saved_auths` scopes by the `owner` field in `.meta.json`; a manifest without it (for example one written by hand) is treated as owned by `local`. - `instructions` on the server and `title` on tools are additive; the golden generator normalizes key order so an SDK reordering does not produce a false diff. diff --git a/specs/03-admin-backend.md b/specs/03-admin-backend.md index ddc2088..7be06d0 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -180,7 +180,7 @@ Conventions (§5) apply to every list. `Auth` column: **S** operator session or ### 4.2 Sessions -`SessionSummary` (the one shape used by lists, detail, and WS): `session_id, slug, owner, tenant_id, channel, engine, headless, incognito, persistence_mode, current_url, created_at, closed_at, closed_reason, archived_at, lease_expires_at, lease_paused_at, lease_remaining_ms, state ('reserved'|'launching'|'live'|'paused'|'draining'|'closed'|'crashed'), live, disable_evaluate, vault_enabled, stealth, fingerprint, humanize, stealth_recorded, identity, browser?:{version, sandboxed}, proxy_label, counts:{tool_calls, errors, pages, blocked, attention_open, vault_access}, has_live_viewers, harness, client:{name, version, agent_name?, model?, harness?, harness_label?, harness_source?, model_source?, workspace?, title?, protocol_version?, meta?}` — `browser` (live sessions only; absent for stored rows) is the running engine's real version (`null` for a persistent context) and whether it runs inside Chromium's sandbox (D-27); `harness` is the harness that launched the session (02 §1.4), fixed at launch (`sessions.harness`), always present: a slug, or `unknown` for sessions whose launch identified nothing and for sessions created before schema v3. `client` is the launching MCP connection's self-reported identity (02 §1.4): `name`/`version`/`title` from `clientInfo`, `protocol_version`, `harness` (slug) with `harness_label` and `harness_source`, `model` with `model_source`, `workspace` (also as `agent_name`, kept for compatibility) and the capped `meta` bag; absent values are omitted. For a live session it is the identity resolved when it launched; for a stored session, the connection row's latest resolution. It is `null` when the session has no connection row, or when the client reported nothing at all (no name, version, workspace or model, and harness `unknown`). +`SessionSummary` (the one shape used by lists, detail, and WS): `session_id, slug, owner, tenant_id, channel, engine, headless, incognito, persistence_mode, current_url, created_at, closed_at, closed_reason, archived_at, lease_expires_at, lease_paused_at, lease_remaining_ms, state ('reserved'|'launching'|'live'|'paused'|'draining'|'closed'|'crashed'), live, disable_evaluate, vault_enabled, stealth, fingerprint, humanize, stealth_recorded, identity, browser?:{version, sandboxed}, proxy_label, counts:{tool_calls, errors, pages, blocked, attention_open, vault_access}, has_live_viewers, harness, client:{name, version, agent_name?, model?, harness?, harness_label?, harness_source?, model_source?, workspace?, title?, protocol_version?, meta?}` — `browser` is the browser's real version (`null` for a persistent context, where Playwright exposes no `Browser`) and whether it runs inside Chromium's sandbox (D-27, D-31): for a session whose browser is running it is read from the live handle; otherwise (closed, crashed, draining after teardown, or served from the stored row) it is the value recorded at launch (`sessions.sandboxed`, `sessions.browser_version`, schema v4). It is **absent** when nothing was recorded: sessions created before schema v4, sessions whose browser never launched (`reserved`, `launching`, `closed_reason = 'launch_failed'`) and drivers that report no launch facts. Absent means "not recorded", never "not sandboxed"; clients MUST NOT read it as `sandboxed: false`; `harness` is the harness that launched the session (02 §1.4), fixed at launch (`sessions.harness`), always present: a slug, or `unknown` for sessions whose launch identified nothing and for sessions created before schema v3. `client` is the launching MCP connection's self-reported identity (02 §1.4): `name`/`version`/`title` from `clientInfo`, `protocol_version`, `harness` (slug) with `harness_label` and `harness_source`, `model` with `model_source`, `workspace` (also as `agent_name`, kept for compatibility) and the capped `meta` bag; absent values are omitted. For a live session it is the identity resolved when it launched; for a stored session, the connection row's latest resolution. It is `null` when the session has no connection row, or when the client reported nothing at all (no name, version, workspace or model, and harness `unknown`). `counts` are live and identical in list rows, `GET /sessions/{id}` (`.session.counts` and top-level `counts`) and WS `session.updated`. For a live session they come from the in-memory aggregate (`app/sessions/session-counters.ts`), which subscribes to the same bus events the recorder persists: `tool.called` → `tool_calls` +1 and `errors` +1 when `error_code` is set (soft failures count); `page.visited` → `pages`; `blocklist.hit` → `blocked`; `vault.access` → `vault_access`; `attention.created`/`attention.resolved` → `attention_open` ±1. Every change publishes `session.updated` (coalesced 250 ms per session). `attention_open` counts pending **attention** requests only (vault confirms excluded, in the DB view too). A blocked `request_attention` counts in `tool_calls` once it returns. The takeover gate needs an open request with `mode = 'takeover'`; `attention_open > 0` alone is not sufficient. @@ -402,7 +402,7 @@ Every event is produced by the app-level event bus (01 §5); the hub only maps b --- -## 7. Data model (schema v3) +## 7. Data model (schema v4) All tables in `browserhive.db` (D-24). Conventions: `TEXT` ids, epoch-ms `INTEGER` columns suffixed `_at`/`_ts`, durations `_ms`, sizes `_bytes`, booleans `INTEGER CHECK IN (0,1)`, enums `TEXT CHECK (col IN (...))` generated from `contracts/enums`, every `session_id` FK `ON DELETE CASCADE`. `WITHOUT ROWID` on tables with a TEXT primary key that are never scanned in insertion order. @@ -449,6 +449,11 @@ CREATE TABLE sessions ( -- v3: the launch harness, written once at creation; NULL (sessions created before v3) reads `unknown` ALTER TABLE sessions ADD COLUMN harness TEXT; CREATE INDEX idx_sessions_harness ON sessions(harness, created_at); +-- v4 (0004-session-browser): what the session's browser actually ran with (D-31), written once the browser has launched +-- (first by the `session.updated` that marks it live, in the `register` phase); a later launch of the same session overwrites it (latest launch wins). +-- NULL = not recorded (sessions created before v4, or whose browser never launched); never backfilled. +ALTER TABLE sessions ADD COLUMN sandboxed INTEGER CHECK (sandboxed IN (0,1)); +ALTER TABLE sessions ADD COLUMN browser_version TEXT; -- NULL also for a persistent context (no `Browser` object) CREATE INDEX idx_sessions_open ON sessions(state) WHERE closed_at IS NULL; CREATE INDEX idx_sessions_owner ON sessions(owner, created_at); CREATE INDEX idx_sessions_created ON sessions(created_at, session_id); @@ -522,7 +527,7 @@ CREATE INDEX idx_logs_ts ON logs(ts); CREATE INDEX idx_logs_trace ON logs(trace_ CREATE TABLE resource_samples (ts INTEGER NOT NULL, session_id TEXT REFERENCES sessions(session_id) ON DELETE CASCADE, cpu_pct REAL, rss_bytes INTEGER, host_free_bytes INTEGER, PRIMARY KEY (ts, session_id)) WITHOUT ROWID; ``` -`meta.min_reader_version = 1`. Migration v1 (`0001-initial`) creates everything above except the v2 and v3 lines; migration v2 (`0002-notification-groups`, `compatible: true`, so the min reader stays 1) adds the notification grouping columns and indexes; migration v3 (`0003-harness-identity`, `compatible: true`) adds the identity columns and indexes, backfills `workspace` from `agent_name` and the two source columns where a value was set, and leaves `sessions.harness` NULL for existing sessions. The runner writes a backup before migrating. A dedicated CI test asserts fresh == migrated (D-04); fixtures `v1.db`, `v2.db` and `v3.db` upgrade to head, and the golden is `schema-v3.json`. +`meta.min_reader_version = 1`. Migration v1 (`0001-initial`) creates everything above except the v2, v3 and v4 lines; migration v2 (`0002-notification-groups`, `compatible: true`, so the min reader stays 1) adds the notification grouping columns and indexes; migration v3 (`0003-harness-identity`, `compatible: true`) adds the identity columns and indexes, backfills `workspace` from `agent_name` and the two source columns where a value was set, and leaves `sessions.harness` NULL for existing sessions; migration v4 (`0004-session-browser`, `compatible: true`) adds `sessions.sandboxed` and `sessions.browser_version`, both NULL for existing sessions (no guess is backfilled: under `sandbox=auto` the answer depends on the browser and the host). The runner writes a backup before migrating. A dedicated CI test asserts fresh == migrated (D-04); fixtures `v1.db`, `v2.db`, `v3.db` and `v4.db` upgrade to head, and the golden is `schema-v4.json`. ### 7.1 Retention classes diff --git a/specs/04-admin-frontend.md b/specs/04-admin-frontend.md index 9a22e2e..9874afd 100644 --- a/specs/04-admin-frontend.md +++ b/specs/04-admin-frontend.md @@ -473,7 +473,7 @@ Search (`features/sessions/detail-search.ts`): `tab` (`activity|screenshots|file - Virtualised (TanStack Virtual) on the page scroller in the document layout (`useWindowVirtualizer`) or on its pane in the workspace. - **Screenshots**: All / Agent captures / Trace frames segmented control (`shots`); a thumbnail grid using each capture's aspect ratio; each card shows the page title at capture time (from `GET /pages?session_id&until=&limit=500`, latest visit at or before the capture), then host · dims · size, then time; falls back to host, then "Screenshot" / "Trace frame · "; zoom modal; pager only when needed. - **Files & trace**: Playwright trace panel (size, Download, Open trace viewer with the disabled reason in a tooltip, `npx playwright show-trace` with copy), data directory (path wrapping at `/`, copy, Show files with result notes), Export activity. -- **Details**: counts strip (tool calls, errors, pages always; blocked and open attention when > 0; vault fills when the vault is on or > 0) whose cells link to Activity with `kinds` / `errors_only`; **Client** panel (D-30): Harness (label, the slug in mono when it differs from the label, and how it was recognised: "declared by header", "from the environment", "set by the harness", "from the URL", "from `_meta`", "from clientInfo", "from the User-Agent", "not identified"), Model (or muted "not reported" with its source when reported), Workspace (or "—"), Client (`name version`, and `title` when it differs), Protocol version; a Meta key/value table only when the bag has entries; the panel's `InfoDot` says "Reported by the client or by your configuration. BrowserHive can't verify it." and links to the harness identity docs. With no connection the panel shows the launch harness and "No client details were recorded". Session panel (state + worded closed reason, owner, browser (channel plus the running engine's real version when the live summary carries `browser`), a Sandbox row for live sessions (`sandboxed` success pill or muted "not sandboxed"), persistence, started/closed/lifetime or lease, last URL, proxy); Identity & coherence (full-width UA with copy, provenance); Browser viewport (`ViewportForm`, `POST /sessions/:id/viewport`) while live; the form says plainly that it changes the agent's real viewport and that pages may re-layout under the agent, because `set_viewport` is not attention-gated (D-10). +- **Details**: counts strip (tool calls, errors, pages always; blocked and open attention when > 0; vault fills when the vault is on or > 0) whose cells link to Activity with `kinds` / `errors_only`; **Client** panel (D-30): Harness (label, the slug in mono when it differs from the label, and how it was recognised: "declared by header", "from the environment", "set by the harness", "from the URL", "from `_meta`", "from clientInfo", "from the User-Agent", "not identified"), Model (or muted "not reported" with its source when reported), Workspace (or "—"), Client (`name version`, and `title` when it differs), Protocol version; a Meta key/value table only when the bag has entries; the panel's `InfoDot` says "Reported by the client or by your configuration. BrowserHive can't verify it." and links to the harness identity docs. With no connection the panel shows the launch harness and "No client details were recorded". Session panel (state + worded closed reason, owner, browser (channel plus the browser's real version when the summary carries `browser` with a version, live or recorded at launch), a Sandbox row for every session (D-31): a `sandboxed` success pill or muted "not sandboxed" from `browser` (the live handle, or what was recorded at launch once the session closed); without `browser`, muted "not launched" when the browser never started (`reserved`/`launching`, or closed with `launch_failed`), otherwise muted "not recorded" with an `InfoDot` ("Recorded for sessions launched with BrowserHive 0.2 or later." and why it is not a "no"), never "no" or "not sandboxed"; persistence, started/closed/lifetime or lease, last URL, proxy); Identity & coherence (full-width UA with copy, provenance); Browser viewport (`ViewportForm`, `POST /sessions/:id/viewport`) while live; the form says plainly that it changes the agent's real viewport and that pages may re-layout under the agent, because `set_viewport` is not attention-gated (D-10). **Live pane** (`live/LivePane.tsx`), toggled by the Live button, `L`, or the palette; state `?live=1`: diff --git a/specs/09-testing.md b/specs/09-testing.md index 63b9cd2..6eaf683 100644 --- a/specs/09-testing.md +++ b/specs/09-testing.md @@ -86,6 +86,7 @@ Domain and application (no I/O, fakes only): - Config resolver (`app/config/resolve.test.ts`): table-driven ladder cases (env < file < cli), shadow lines exact text, secrets redacted in shadow lines, derived provenance (`trace`, `fingerprint`, `maxSessions`, `dataDir`), every fail-fast message in `08-cli-arguments-and-config.md` §4 reproduced exactly, "did you mean" suggestions and the unsupported spellings of 08 §5.5, reserved keys rejected, empty env rejected, relative path resolution against cwd vs config-file dir, the data-dir-config rule, OTEL sub-source precedence, and config-file references (08 §3.1): the scanner table in `contracts/test/config.refs.test.ts` (whole, embedded, several per string, defaults with unset/empty/set, the escape, `{trace_id}` and other literal braces, malformed names, unknown and mis-cased schemes, `${env:…}`, no rescanning), the tree walk in `app/config/interpolate.test.ts` (array elements, object values, nested objects, keys never expanded, `at` paths), ladder rows with references (file beats env, shadowed by cli, secret key, default used, exact shadow-line text), every reference message of 08 §4 verbatim, several bad references reported together, and the warning for reference-shaped text in env, `OTEL_*`, flags and options. **Invariant** (`interface/http/serializers/config-refs.redaction.test.ts`): a sentinel routed through a reference into a secret key, and into a key whose reference name is credential-looking, appears in no shadow line, `config show` row, `configView`, `configKeysToWire` output or problem message. - Migration runner (`infra/persistence/migrations/runner.test.ts`, file-backed temp DB): fresh DB ends at `SCHEMA_VERSION`; each fixture DB `packages/core/test/persistence/fixtures/v.db` upgrades to head (`test/persistence/fixtures.test.ts`; one fixture per schema version, written by `generate-fixture.ts`) and its pragma-normalised fingerprint equals a fresh install; backup file is created before any DDL and retained per `backupsKeep`; a migration that throws mid-way leaves `user_version` unchanged and no partial schema (crash/rollback); a DB with `user_version > SCHEMA_VERSION` and `min_reader_version <= SCHEMA_VERSION` opens (compat window); with `min_reader_version > SCHEMA_VERSION` it refuses with `DB_NEWER_THAN_BINARY` naming the backup; `application_id` mismatch refuses; a second opener (`purge` inventory) never deadlocks; `db.exec` is used (a test asserts that a multi-statement migration executes all statements). - Schema snapshot (`test/persistence/schema-fingerprint.test.ts`): pragma-normalised schema of a fresh DB equals the committed golden `test/persistence/fixtures/schema-v.json`; adding a migration without updating the golden fails. +- Session browser record (D-31): `test/persistence/session-browser-migration.test.ts` upgrades `v3.db` to v4 and asserts every existing session keeps `sandboxed` and `browser_version` NULL and serves no `browser` (list and detail), that the columns round-trip through insert and update, and that the CHECK refuses a value other than 0/1; `app/sessions/metadata.test.ts` pins the write path (a reserved session's record and patch carry nothing, a launched one carries the handle's facts, a detached one keeps them, a later launch overwrites them, a persistent context records a NULL version with its verdict) and the summary fallback (live handle first, then the recorded launch); `interface/http/serializers/serializers.test.ts` pins the stored row and the live overlay (a recorded row serves `browser`, a NULL row omits it, the live value wins); the dashboard tests cover the four Sandbox states (sandboxed, not sandboxed, not launched, not recorded). - Repository conformance (`infra/persistence/repositories.conformance.test.ts`): one suite parameterized over adapters; runs against the SQLite adapter on `:memory:` today and is the acceptance suite for any future adapter. Covers every repository method, keyset pagination stability under concurrent inserts, `ON DELETE CASCADE` for every `session_id` FK, `INSERT` idempotency on primary keys, transaction rollback via `UnitOfWork`. - Retention (`app/observability/retention.test.ts`): every table has a retention class asserted over the table list; age prune; byte cap converges without `VACUUM` in a loop; artifact deletion via the outbox; `pending_attention`/operator-request rows are pruned once terminal; failures are counted and surfaced, never thrown out of the timer. - Error registry (`kernel/errors/*.test.ts`): every code projects to MCP, problem+json, and WS frames; `docs/errors.md` generation is deterministic; `INTERNAL_ERROR` never carries host paths in the public message. diff --git a/specs/11-stealth.md b/specs/11-stealth.md index 01117f3..e1dce38 100644 --- a/specs/11-stealth.md +++ b/specs/11-stealth.md @@ -17,7 +17,7 @@ related: > RFC 2119 when they appear in uppercase. `D-NN` identifiers refer to entries in the > [decision log](00-decisions.md). Terminology follows the [specification index](README.md#conventions). -Decisions: D-13 (stealth posture, proxy seam), D-21 (creation pipeline), D-22 (blocklist reload), D-20 (privacy), D-25 (not built), D-26 (browser choice), D-27 (sandbox). +Decisions: D-13 (stealth posture, proxy seam), D-21 (creation pipeline), D-22 (blocklist reload), D-20 (privacy), D-25 (not built), D-26 (browser choice), D-27 (sandbox), D-31 (per-session sandbox record). The rule of this spec: **the stealth posture described here is the acceptance bar.** Every mechanism, constant and downgrade rule below is specified exactly; the integration probes in §9 are the regression gate, and §10 lists the invariants a change MUST NOT break. @@ -173,6 +173,8 @@ Facts behind it (measured through BrowserHive on GitHub's runners and an AppArmo Mechanics (`infra/browsers/sandbox-policy.ts`): one verdict per executable for the process lifetime. Under `auto` the first real launch tries the sandbox; if it fails and the same browser then starts without it, the verdict is "unavailable", sessions fall back and one `warn` log `sandbox fell back` names the channel, executable and Chrome's reason; concurrent first launches wait for that one attempt. A failure is blamed on the sandbox only when the unsandboxed launch works, so a browser broken for another reason surfaces its own error. Under `on` the boot preflight (`composition/sandbox.ts`) launches the configured browser once with the sandbox forced on before the listeners open and, if it cannot, probes the other installed browsers and refuses to start; a session for a browser that cannot sandbox fails typed (a proof launch without the sandbox is closed at once, so the session never runs unsandboxed). An agent's `launch_options.chromiumSandbox: true` is a requirement for that session in every mode; `false` is refused (§4). A launch failure caused by the sandbox is `SANDBOX_UNAVAILABLE` in every mode, never `INTERNAL_ERROR`. +What each session actually ran with is recorded, not inferred (D-31): once its browser has launched, the verdict the policy applied (`sandboxed`) and the browser's real version are stored on the session row (`sessions.sandboxed`, `sessions.browser_version`, 03 §7) and served as `SessionSummary.browser` for live and closed sessions alike. Sessions from before schema v4, and sessions whose browser never launched, have no record and read "not recorded", never "not sandboxed". + **Sandbox on and off produce identical page-visible signals.** Measured on all three OSes for `chromium`, `chrome` and `edge` at both stealth levels (plan Phase 0 and the `sandbox-matrix` CI job) and asserted by `packages/browserhive/test/integration/sandbox-stealth.test.ts`. `browserhive doctor --printApparmorProfile` prints a profile for the configured browser shaped like Ubuntu's own `/etc/apparmor.d/chrome` (`flags=(unconfined)` plus `userns`); BrowserHive never installs it. From 9b1bd3b72f9f86dfbd2ca657024b9b3dbead5003 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Sat, 26 Sep 2026 22:20:24 -0400 Subject: [PATCH 2/5] feat(sessions): record each session's sandbox state and browser version at launch (schema v4) Migration 0004-session-browser (compatible) adds sessions.sandboxed (INTEGER CHECK IN (0,1)) and sessions.browser_version, NULL for existing rows. The Session aggregate keeps the launch facts it attached after teardown (latest launch wins); the session.updated patch writes them once known and never clears them. SessionSummary.browser falls back to the recorded launch in every builder: the aggregate (HTTP detail/list overlay, WS), the stored row and the live overlay. Absent still means not recorded. Fixture v4.db, golden schema-v4.json, db types regenerated. --- packages/contracts/src/http/sessions.ts | 6 +- .../core/src/app/sessions/metadata.test.ts | 89 +- packages/core/src/app/sessions/metadata.ts | 30 +- packages/core/src/domain/session/session.ts | 20 +- .../src/infra/persistence/generated/db.d.ts | 2 + .../src/infra/persistence/mappers/session.ts | 8 + .../migrations/0004-session-browser.ts | 20 + .../src/infra/persistence/migrations/index.ts | 8 +- .../http/serializers/serializers.test.ts | 34 +- .../interface/http/serializers/sessions.ts | 16 +- .../core/src/ports/persistence/records.ts | 9 + .../core/test/goldens/http/listSessions.json | 4 + .../core/test/helpers/fake-session-handle.ts | 5 + packages/core/test/helpers/http-fixtures.ts | 9 +- .../conformance-operations.test.ts | 1 + .../persistence/fixtures/generate-fixture.ts | 9 +- .../test/persistence/fixtures/schema-v4.json | 4160 +++++++++++++++++ packages/core/test/persistence/fixtures/v4.db | Bin 0 -> 348160 bytes .../persistence/harness-migration.test.ts | 4 +- packages/core/test/persistence/helpers.ts | 2 + .../session-browser-migration.test.ts | 110 + 21 files changed, 4529 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/infra/persistence/migrations/0004-session-browser.ts create mode 100644 packages/core/test/persistence/fixtures/schema-v4.json create mode 100644 packages/core/test/persistence/fixtures/v4.db create mode 100644 packages/core/test/persistence/session-browser-migration.test.ts diff --git a/packages/contracts/src/http/sessions.ts b/packages/contracts/src/http/sessions.ts index e1cb72b..7863fef 100644 --- a/packages/contracts/src/http/sessions.ts +++ b/packages/contracts/src/http/sessions.ts @@ -102,7 +102,11 @@ export const SessionSummary = z.object({ humanize: z.boolean(), stealth_recorded: z.boolean(), identity: AppliedIdentity.nullable(), - /** The running browser's real version (null for a persistent context) and whether it runs sandboxed; live sessions only. */ + /** + * The browser's real version (null for a persistent context) and whether it runs sandboxed (D-31): + * from the live browser while it runs, else as recorded at launch (schema v4). Absent = not recorded + * (sessions from before schema v4, or whose browser never launched), never "not sandboxed". + */ browser: z.object({ version: z.string().nullable(), sandboxed: z.boolean() }).optional(), proxy_label: z.string().nullable(), counts: SessionCounts, diff --git a/packages/core/src/app/sessions/metadata.test.ts b/packages/core/src/app/sessions/metadata.test.ts index 3ba15ca..e21c4cd 100644 --- a/packages/core/src/app/sessions/metadata.test.ts +++ b/packages/core/src/app/sessions/metadata.test.ts @@ -2,8 +2,15 @@ import { describe, expect, it } from 'bun:test'; import { SessionSummary } from '@browserhive/contracts/http'; import { SessionMetadata } from '@browserhive/contracts/tools'; -import type { AppliedIdentity } from '../../ports/browser-driver.ts'; -import { configJson, toSessionMetadata, toSessionRecord, toSessionSummary } from './metadata.ts'; +import { FakeSessionHandle } from '../../../test/helpers/fake-session-handle.ts'; +import type { AppliedIdentity, SessionBrowserInfo } from '../../ports/browser-driver.ts'; +import { + configJson, + toSessionMetadata, + toSessionPatch, + toSessionRecord, + toSessionSummary, +} from './metadata.ts'; import { testSession } from './test-support.ts'; const T0 = 1_700_000_000_000; @@ -121,4 +128,82 @@ describe('session metadata projections', () => { expect(closed.closedReason).toBe('user'); expect(closed.closedAt).toBe(T0 + 6); }); + + describe('the launch record of the browser (D-31)', () => { + function launched(info?: SessionBrowserInfo) { + const session = testSession(); + session.apply({ type: 'launch', phase: 'launch', at: T0 + 1 }); + const handle = new FakeSessionHandle(session.id, { + ...(info !== undefined && { browserInfo: info }), + }); + session.attach({ handle, driver: 'playwright', launchedAt: T0 + 2, launchMs: 2 }); + session.apply({ type: 'launched', at: T0 + 2 }); + return { session, handle }; + } + + it('a reserved session records nothing and its patch leaves the columns alone', () => { + const session = testSession(); + const record = toSessionRecord(session); + expect(record.sandboxed).toBeNull(); + expect(record.browserVersion).toBeNull(); + expect(toSessionPatch(session)).not.toHaveProperty('sandboxed'); + expect(toSessionPatch(session)).not.toHaveProperty('browserVersion'); + expect(toSessionSummary(session, T0)).not.toHaveProperty('browser'); + }); + + it('a launched session writes what the handle reported and serves it', () => { + const { session } = launched({ version: '154.0.8037.57', sandboxed: true }); + expect(toSessionPatch(session)).toMatchObject({ + sandboxed: true, + browserVersion: '154.0.8037.57', + }); + const summary = toSessionSummary(session, T0 + 3); + expect(summary.browser).toEqual({ version: '154.0.8037.57', sandboxed: true }); + expect(SessionSummary.parse(summary)).toEqual(summary); + }); + + it('keeps the record after teardown, so a closed aggregate still serves it', () => { + const { session } = launched({ version: '153.0.8010.12', sandboxed: false }); + session.apply({ type: 'drain', reason: 'user', at: T0 + 5 }); + session.apply({ type: 'closed', at: T0 + 6 }); + session.detach(); + expect(session.handle).toBeNull(); + expect(toSessionSummary(session, T0 + 7).browser).toEqual({ + version: '153.0.8010.12', + sandboxed: false, + }); + expect(toSessionPatch(session)).toMatchObject({ + sandboxed: false, + browserVersion: '153.0.8010.12', + }); + }); + + it('the latest launch wins', () => { + const { session } = launched({ version: '153.0.8010.12', sandboxed: false }); + session.attach({ + handle: new FakeSessionHandle(session.id, { + browserInfo: { version: '154.0.8037.57', sandboxed: true }, + }), + driver: 'playwright', + launchedAt: T0 + 9, + launchMs: 9, + }); + expect(toSessionPatch(session)).toMatchObject({ + sandboxed: true, + browserVersion: '154.0.8037.57', + }); + }); + + it('a persistent context records its verdict with a null version', () => { + const { session } = launched({ version: null, sandboxed: true }); + expect(toSessionPatch(session)).toMatchObject({ sandboxed: true, browserVersion: null }); + expect(toSessionSummary(session, T0 + 3).browser).toEqual({ version: null, sandboxed: true }); + }); + + it('a driver that reports nothing records nothing', () => { + const { session } = launched(); + expect(toSessionPatch(session)).not.toHaveProperty('sandboxed'); + expect(toSessionSummary(session, T0 + 3)).not.toHaveProperty('browser'); + }); + }); }); diff --git a/packages/core/src/app/sessions/metadata.ts b/packages/core/src/app/sessions/metadata.ts index 7d20b8d..827d630 100644 --- a/packages/core/src/app/sessions/metadata.ts +++ b/packages/core/src/app/sessions/metadata.ts @@ -126,6 +126,26 @@ export function launchHarnessOf(client: SessionClientInfo | null): string | null return client?.harness ?? null; } +/** + * `SessionSummary.browser` of an aggregate (D-31): the live handle's facts while the browser runs, + * else what its launch recorded (kept after teardown); absent when nothing was recorded. + */ +function browserOf(session: Session): Pick { + const info = session.handle?.browserInfo ?? session.browserInfo; + return info === undefined || info === null + ? {} + : { browser: { version: info.version, sandboxed: info.sandboxed } }; +} + +/** The recorded launch facts as columns: both `null` until a browser that reports them launched. */ +function launchColumns(session: Session): Pick { + const info = session.browserInfo; + return { + sandboxed: info?.sandboxed ?? null, + browserVersion: info?.version ?? null, + }; +} + /** The one HTTP/WS shape (spec 03 §4.2). `has_live_viewers` is enriched by the interface layer. */ export function toSessionSummary(session: Session, now: number): SessionSummary { const request = session.request; @@ -157,12 +177,7 @@ export function toSessionSummary(session: Session, now: number): SessionSummary humanize: request.humanize, stealth_recorded: request.stealth && session.identity !== null, identity: identityJson(session), - ...(session.handle?.browserInfo !== undefined && { - browser: { - version: session.handle.browserInfo.version, - sandboxed: session.handle.browserInfo.sandboxed, - }, - }), + ...browserOf(session), proxy_label: session.proxyLabel, counts: { tool_calls: session.counts.toolCalls, @@ -235,6 +250,7 @@ export function toSessionRecord(session: Session): SessionRecord { launchMs: session.launchMs, config: configJson(session), harness: launchHarnessOf(request.client), + ...launchColumns(session), }; } @@ -252,5 +268,7 @@ export function toSessionPatch(session: Session): SessionPatch { launchMs: session.launchMs, closedAt: session.closedAt, closedReason: session.closedReason, + // Only once a launch recorded them, so no patch ever clears a stored record (D-31). + ...(session.browserInfo !== null && launchColumns(session)), }; } diff --git a/packages/core/src/domain/session/session.ts b/packages/core/src/domain/session/session.ts index 5b12b29..742b489 100644 --- a/packages/core/src/domain/session/session.ts +++ b/packages/core/src/domain/session/session.ts @@ -3,7 +3,11 @@ import type { ClosedReason, StealthDriverName } from '@browserhive/contracts/enums'; import { AppError } from '../../kernel/errors/app-error.ts'; import type { Result } from '../../kernel/result.ts'; -import type { AppliedIdentity, SessionHandle } from '../../ports/browser-driver.ts'; +import type { + AppliedIdentity, + SessionBrowserInfo, + SessionHandle, +} from '../../ports/browser-driver.ts'; import type { CreateSessionRequest } from './create-request.ts'; import { isLeaseExpired, @@ -82,6 +86,7 @@ export class Session { private currentState: SessionState; private currentLease: Lease; private attachment: LaunchAttachment | null = null; + private launchBrowser: SessionBrowserInfo | null = null; private appliedIdentity: AppliedIdentity | null = null; private label: string | null = null; private restoredSeed: string | null = null; @@ -128,6 +133,15 @@ export class Session { return this.attachment?.driver ?? null; } + /** + * What the latest launch reported about the browser (real version, sandbox verdict), kept after + * teardown so a closed session still knows what it ran with (D-31). `null` until a browser that + * reports it has launched. + */ + get browserInfo(): SessionBrowserInfo | null { + return this.launchBrowser; + } + /** Epoch ms the browser became usable, once known. */ get launchedAt(): number | null { return this.attachment?.launchedAt ?? null; @@ -261,6 +275,10 @@ export class Session { attach(attachment: LaunchAttachment): void { this.attachment = attachment; this.appliedIdentity = attachment.handle.identity; + // The latest launch wins (D-31); `detach` keeps it. + const info = attachment.handle.browserInfo; + this.launchBrowser = + info === undefined ? null : { version: info.version, sandboxed: info.sandboxed }; } /** Drops the driver handle after teardown so nothing can drive a closed browser. */ diff --git a/packages/core/src/infra/persistence/generated/db.d.ts b/packages/core/src/infra/persistence/generated/db.d.ts index 6e8a34d..dc871aa 100644 --- a/packages/core/src/infra/persistence/generated/db.d.ts +++ b/packages/core/src/infra/persistence/generated/db.d.ts @@ -253,6 +253,7 @@ export interface Screenshots { export interface Sessions { archived_at: number | null; + browser_version: string | null; channel: string; closed_at: number | null; closed_reason: string | null; @@ -276,6 +277,7 @@ export interface Sessions { owner: string; persistence_mode: string; proxy_label: string | null; + sandboxed: number | null; session_id: string; slug: string; state: string; diff --git a/packages/core/src/infra/persistence/mappers/session.ts b/packages/core/src/infra/persistence/mappers/session.ts index 4c4b838..998053d 100644 --- a/packages/core/src/infra/persistence/mappers/session.ts +++ b/packages/core/src/infra/persistence/mappers/session.ts @@ -55,6 +55,8 @@ export function sessionFromRow(row: Selectable): SessionRecord { launchMs: row.launch_ms, config: parseJsonObject(row.config_json, where), harness: row.harness, + sandboxed: row.sandboxed === null ? null : intToBool(row.sandboxed), + browserVersion: row.browser_version, }; } @@ -91,6 +93,8 @@ export function sessionToRow(record: SessionRecord): Selectable { launch_ms: record.launchMs, config_json: toJson(record.config), harness: record.harness, + sandboxed: record.sandboxed === null ? null : boolToInt(record.sandboxed), + browser_version: record.browserVersion, }; } @@ -109,5 +113,9 @@ export function sessionPatchToRow(patch: SessionPatch): Updateable { ...(patch.launchMs !== undefined && { launch_ms: patch.launchMs }), ...(patch.closedAt !== undefined && { closed_at: patch.closedAt }), ...(patch.closedReason !== undefined && { closed_reason: patch.closedReason }), + ...(patch.sandboxed !== undefined && { + sandboxed: patch.sandboxed === null ? null : boolToInt(patch.sandboxed), + }), + ...(patch.browserVersion !== undefined && { browser_version: patch.browserVersion }), }; } diff --git a/packages/core/src/infra/persistence/migrations/0004-session-browser.ts b/packages/core/src/infra/persistence/migrations/0004-session-browser.ts new file mode 100644 index 0000000..74f19bb --- /dev/null +++ b/packages/core/src/infra/persistence/migrations/0004-session-browser.ts @@ -0,0 +1,20 @@ +/** @module infra/persistence/migrations/0004-session-browser — schema v4: what each session's browser ran with, recorded at launch: the sandbox verdict and the real browser version (spec 03 §7, D-31). */ + +import type { Migration } from './migration.ts'; + +const sql = ` +ALTER TABLE sessions ADD COLUMN sandboxed INTEGER CHECK (sandboxed IN (0,1)); +ALTER TABLE sessions ADD COLUMN browser_version TEXT; +`; + +/** + * Schema v4. `compatible`: purely additive, so a v3 reader still understands every table. Existing + * sessions keep NULL in both columns, which reads "not recorded": nothing is backfilled, because under + * `sandbox=auto` the verdict depended on the browser and the host at the time. + */ +export const sessionBrowser: Migration = { + version: 4, + name: 'session-browser', + compatible: true, + sql, +}; diff --git a/packages/core/src/infra/persistence/migrations/index.ts b/packages/core/src/infra/persistence/migrations/index.ts index 95f53cc..0c5ec47 100644 --- a/packages/core/src/infra/persistence/migrations/index.ts +++ b/packages/core/src/infra/persistence/migrations/index.ts @@ -3,12 +3,18 @@ import { initial } from './0001-initial.ts'; import { notificationGroups } from './0002-notification-groups.ts'; import { harnessIdentity } from './0003-harness-identity.ts'; +import { sessionBrowser } from './0004-session-browser.ts'; import type { Migration } from './migration.ts'; export type { Migration } from './migration.ts'; /** Every migration in apply order. Append only; never edit a shipped entry. */ -export const MIGRATIONS: readonly Migration[] = [initial, notificationGroups, harnessIdentity]; +export const MIGRATIONS: readonly Migration[] = [ + initial, + notificationGroups, + harnessIdentity, + sessionBrowser, +]; /** The schema version this binary writes. */ export const SCHEMA_VERSION: number = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0; diff --git a/packages/core/src/interface/http/serializers/serializers.test.ts b/packages/core/src/interface/http/serializers/serializers.test.ts index 02cc13a..9c3b5e9 100644 --- a/packages/core/src/interface/http/serializers/serializers.test.ts +++ b/packages/core/src/interface/http/serializers/serializers.test.ts @@ -28,7 +28,7 @@ import { toolCallToWire, } from './facts.ts'; import { envelope } from './page.ts'; -import { sessionRowToSummary, sessionsQueryToRepo } from './sessions.ts'; +import { overlayLive, sessionRowToSummary, sessionsQueryToRepo } from './sessions.ts'; import { configKeysToWire, notificationToWire, systemEventToWire } from './system.ts'; import { timelineItemToWire } from './timeline.ts'; import { bindingInputFromWire, bindingToWire, overviewToWire, policyToWire } from './vault.ts'; @@ -57,6 +57,38 @@ const call = { }; describe('serializers', () => { + it('session rows serve the browser recorded at launch, and omit it when not recorded (D-31)', () => { + const recorded = { + ...sessionRecord({ sandboxed: false, browserVersion: '153.0.8010.12' }), + counts, + client: null, + }; + expect(SessionSummary.parse(sessionRowToSummary(recorded, NOW, false)).browser).toEqual({ + version: '153.0.8010.12', + sandboxed: false, + }); + const unrecorded = { ...sessionRecord(), counts, client: null }; + const wire = sessionRowToSummary(unrecorded, NOW, false); + expect(wire).not.toHaveProperty('browser'); + expect(SessionSummary.parse(wire)).not.toHaveProperty('browser'); + // The live aggregate's value wins; without one the row's record fills in. + const live = SessionSummary.parse({ ...wire, live: true, state: 'live' }); + const withLive = SessionSummary.parse({ + ...live, + browser: { version: '154.0.8037.57', sandboxed: true }, + }); + expect(overlayLive(withLive, recorded, false).browser).toEqual({ + version: '154.0.8037.57', + sandboxed: true, + }); + expect(overlayLive(live, recorded, false).browser).toEqual({ + version: '153.0.8010.12', + sandboxed: false, + }); + expect(overlayLive(live, unrecorded, false)).not.toHaveProperty('browser'); + expect(overlayLive(live, null, false)).not.toHaveProperty('browser'); + }); + it('session rows', () => { const wire = sessionRowToSummary({ ...sessionRecord(), counts, client: null }, NOW, true); const parsed = SessionSummary.parse(wire); diff --git a/packages/core/src/interface/http/serializers/sessions.ts b/packages/core/src/interface/http/serializers/sessions.ts index 803a1c1..c093982 100644 --- a/packages/core/src/interface/http/serializers/sessions.ts +++ b/packages/core/src/interface/http/serializers/sessions.ts @@ -52,6 +52,7 @@ export function sessionRowToSummary( humanize: row.humanize, stealth_recorded: row.stealth && row.identity !== null, identity: row.identity === null ? null : { ...row.identity }, + ...recordedBrowser(row), proxy_label: row.proxyLabel, counts: countsOf(row), has_live_viewers: hasLiveViewers, @@ -60,6 +61,15 @@ export function sessionRowToSummary( }; } +/** + * `browser` as recorded at launch (D-31): present only when the row holds a verdict, so a session from + * before schema v4, or whose browser never launched, reads "not recorded" rather than "not sandboxed". + */ +export function recordedBrowser(row: SessionListRow | null): Pick { + if (row === null || row.sandboxed === null) return {}; + return { browser: { version: row.browserVersion, sandboxed: row.sandboxed } }; +} + /** Per-session counters of a row. */ export function countsOf(row: SessionListRow): WireSessionSummary['counts'] { return { @@ -73,8 +83,9 @@ export function countsOf(row: SessionListRow): WireSessionSummary['counts'] { } /** - * Overlays the live aggregate's summary on a stored row: live state, URL, lease and counters win; - * the row supplies `archived_at` (the aggregate never knows it). + * Overlays the live aggregate's summary on a stored row: live state, URL, lease, counters and the live + * `browser` win; the row supplies `archived_at` (the aggregate never knows it) and, when the aggregate + * has no launch facts, the `browser` recorded at launch. */ export function overlayLive( live: z.output, @@ -82,6 +93,7 @@ export function overlayLive( hasLiveViewers: boolean, ): WireSessionSummary { return { + ...recordedBrowser(row), ...live, archived_at: row?.archivedAt ?? live.archived_at, // Both come from the same connection; the row covers a session whose aggregate predates it. diff --git a/packages/core/src/ports/persistence/records.ts b/packages/core/src/ports/persistence/records.ts index 2a0059d..1b73f1f 100644 --- a/packages/core/src/ports/persistence/records.ts +++ b/packages/core/src/ports/persistence/records.ts @@ -95,6 +95,13 @@ export interface SessionRecord { readonly config: JsonObject; /** The launch harness (spec 02 §1.4), written once; `null` for sessions created before schema v3. */ readonly harness: string | null; + /** + * Whether the session's browser ran inside Chromium's sandbox, recorded once it launched (D-31); + * `null` = not recorded (created before schema v4, or the browser never launched). + */ + readonly sandboxed: boolean | null; + /** The launched browser's real version (D-31); `null` when not recorded or for a persistent context. */ + readonly browserVersion: string | null; } /** Mutable subset of {@link SessionRecord} accepted by `SessionRepository.update`. */ @@ -113,6 +120,8 @@ export type SessionPatch = Partial< | 'launchMs' | 'closedAt' | 'closedReason' + | 'sandboxed' + | 'browserVersion' > >; diff --git a/packages/core/test/goldens/http/listSessions.json b/packages/core/test/goldens/http/listSessions.json index f62d3df..c2ad4ff 100644 --- a/packages/core/test/goldens/http/listSessions.json +++ b/packages/core/test/goldens/http/listSessions.json @@ -28,6 +28,10 @@ "humanize": false, "stealth_recorded": false, "identity": null, + "browser": { + "version": "154.0.8037.57", + "sandboxed": true + }, "proxy_label": null, "counts": { "tool_calls": 2, diff --git a/packages/core/test/helpers/fake-session-handle.ts b/packages/core/test/helpers/fake-session-handle.ts index f017610..a476c1a 100644 --- a/packages/core/test/helpers/fake-session-handle.ts +++ b/packages/core/test/helpers/fake-session-handle.ts @@ -5,6 +5,7 @@ import type { AppliedIdentity, EngineCapabilities, LaunchWarning, + SessionBrowserInfo, SessionHandle, TracingHandle, } from '../../src/ports/browser-driver.ts'; @@ -141,6 +142,8 @@ export interface FakeSessionHandleOptions { readonly closeWarnings?: readonly LaunchWarning[]; /** `null` mimics a persistent context (no separate browser). */ readonly browser?: Browser | null; + /** The launch facts a real driver reports (D-31); absent by default, like other fakes. */ + readonly browserInfo?: SessionBrowserInfo; } /** Recorded `close()` invocation. */ @@ -169,6 +172,7 @@ export class FakeSessionHandle implements SessionHandle { readonly capabilities: EngineCapabilities; readonly driver: 'patchright' | 'playwright'; readonly identity: AppliedIdentity | null; + readonly browserInfo?: SessionBrowserInfo; readonly warnings: readonly LaunchWarning[]; readonly tracing: FakeTracingHandle | null; /** Pages `ensureIdentityForPage` was called with. */ @@ -188,6 +192,7 @@ export class FakeSessionHandle implements SessionHandle { this.capabilities = { ...DEFAULT_CAPABILITIES, ...options.capabilities }; this.driver = options.driver ?? 'playwright'; this.identity = options.identity ?? null; + if (options.browserInfo !== undefined) this.browserInfo = options.browserInfo; this.warnings = options.warnings ?? []; this.tracing = options.tracing === undefined || options.tracing === false diff --git a/packages/core/test/helpers/http-fixtures.ts b/packages/core/test/helpers/http-fixtures.ts index ae6cb92..048ed29 100644 --- a/packages/core/test/helpers/http-fixtures.ts +++ b/packages/core/test/helpers/http-fixtures.ts @@ -55,6 +55,8 @@ export function sessionRecord(overrides: Partial = {}): SessionRe launchMs: 1000, config: {}, harness: null, + sandboxed: null, + browserVersion: null, ...overrides, }; } @@ -115,7 +117,12 @@ export async function seedDataset( closedAt: NOW - 400_000, }); await repos.sessions.insert( - sessionRecord({ connectionId: 'c-seed000001', harness: 'claude-code' }), + sessionRecord({ + connectionId: 'c-seed000001', + harness: 'claude-code', + sandboxed: true, + browserVersion: '154.0.8037.57', + }), ); await repos.sessions.insert( sessionRecord({ diff --git a/packages/core/test/persistence/conformance-operations.test.ts b/packages/core/test/persistence/conformance-operations.test.ts index e2ff66c..388c746 100644 --- a/packages/core/test/persistence/conformance-operations.test.ts +++ b/packages/core/test/persistence/conformance-operations.test.ts @@ -416,6 +416,7 @@ describe('SchemaMigrationRepository', () => { [1, 'initial'], [2, 'notification-groups'], [3, 'harness-identity'], + [4, 'session-browser'], ]); expect(await t.repos.schemaMigrations.currentVersion()).toBe(SCHEMA_VERSION); }); diff --git a/packages/core/test/persistence/fixtures/generate-fixture.ts b/packages/core/test/persistence/fixtures/generate-fixture.ts index 257968f..106a3a4 100644 --- a/packages/core/test/persistence/fixtures/generate-fixture.ts +++ b/packages/core/test/persistence/fixtures/generate-fixture.ts @@ -97,7 +97,14 @@ export async function seedFixture(uow: SqliteUnitOfWork): Promise { lastSeenAt: at, closedAt: null, }); - await r.sessions.insert(sessionRecord({ connectionId: 'c-1', harness: 'claude-code' })); + await r.sessions.insert( + sessionRecord({ + connectionId: 'c-1', + harness: 'claude-code', + sandboxed: true, + browserVersion: '154.0.8037.57', + }), + ); await r.sessions.insert( sessionRecord({ sessionId: 'old-11111111', diff --git a/packages/core/test/persistence/fixtures/schema-v4.json b/packages/core/test/persistence/fixtures/schema-v4.json new file mode 100644 index 0000000..51759ff --- /dev/null +++ b/packages/core/test/persistence/fixtures/schema-v4.json @@ -0,0 +1,4160 @@ +{ + "tables": [ + { + "name": "artifact_outbox", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "outbox_id", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "path", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "enqueued_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": "0", + "hidden": 0, + "name": "attempts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "auth_events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_auth_events_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "auth_sessions", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "auth_session_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "revoked_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_auth_sessions_principal", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "auth_session_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "blocked_requests", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "domain", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "pattern", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "source", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_blocked_domain", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 4, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "domain", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_pattern", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "pattern", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "credentials", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "credential_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "public_prefix", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "secret_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "display", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "scopes_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "last_used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "revoked_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_credentials_prefix", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "public_prefix", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "credential_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_credentials_principal", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "credential_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "actor_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "actor_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "payload_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_events_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_events_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_events_type_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "type", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "grants", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "grant_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "auth_session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "route", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "resource_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "auth_session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "auth_sessions", + "to": "auth_session_id" + } + ] + }, + { + "name": "idempotency_keys", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "route", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "response_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "logs", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "level", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "module", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "msg", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "span_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "request_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "principal", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "fields_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_logs_session", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_logs_trace", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "trace_id", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_logs_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "mcp_connections", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "transport", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "mcp_session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "client_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "client_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "protocol_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "capabilities_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "agent_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "model", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "harness", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "connected_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "closed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "client_title", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "workspace", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "harness_source", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "model_source", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "harness_conflicts_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 21, + "dflt_value": null, + "hidden": 0, + "name": "meta_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_mcp_connections_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 14, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "last_seen_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "connection_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_mcp_connections_seen", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 14, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "last_seen_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "connection_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "meta", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "value", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "notifications", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "body", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "target", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "source_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "read_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "dismissed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "0", + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": "1", + "hidden": 0, + "name": "count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "group_key", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_notifications_group", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "group_key", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_inbox", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_updated", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "operator_actions", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "action", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "resource_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "resource_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_operator_actions_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "operator_requests", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "request_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "owner", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "mode", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "entry_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "page_url", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "options_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "idempotency_key", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "message", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "resolved_by", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "resolution_reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "deadline_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "resolved_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_operator_requests_history", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "kind", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_idem", + "unique": 1, + "partial": 1, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "idempotency_key", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "kind", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "pages", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tab_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "domain", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "category", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_pages_category_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 6, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "category", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_domain", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "domain", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_session_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "preferences", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "value_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "principals", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "display", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "0", + "hidden": 0, + "name": "must_change_password", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "disabled_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "resource_samples", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "cpu_pct", + "notnull": 0, + "pk": 0, + "type": "REAL" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "rss_bytes", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "host_free_bytes", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "schema_migrations", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "applied_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "app_version", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "screenshots", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "path", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "content_type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "width", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "height", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "size_bytes", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_screenshots_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + }, + { + "from": "event_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "tool_calls", + "to": "event_id" + } + ] + }, + { + "name": "sessions", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "slug", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "owner", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": "'chromium'", + "hidden": 0, + "name": "engine", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "channel", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "headless", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "0", + "hidden": 0, + "name": "incognito", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "persistence_mode", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": "0", + "hidden": 0, + "name": "disable_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "1", + "hidden": 0, + "name": "vault_enabled", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "stealth", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "fingerprint", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "humanize", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "identity_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "proxy_label", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "state", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "launched_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "last_activity_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 21, + "dflt_value": null, + "hidden": 0, + "name": "lease_expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 22, + "dflt_value": null, + "hidden": 0, + "name": "lease_paused_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 23, + "dflt_value": null, + "hidden": 0, + "name": "closed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 24, + "dflt_value": null, + "hidden": 0, + "name": "closed_reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 25, + "dflt_value": null, + "hidden": 0, + "name": "archived_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 26, + "dflt_value": null, + "hidden": 0, + "name": "last_url", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 27, + "dflt_value": null, + "hidden": 0, + "name": "launch_ms", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 28, + "dflt_value": null, + "hidden": 0, + "name": "config_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 29, + "dflt_value": null, + "hidden": 0, + "name": "harness", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 30, + "dflt_value": null, + "hidden": 0, + "name": "sandboxed", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 31, + "dflt_value": null, + "hidden": 0, + "name": "browser_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_sessions_archived", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 25, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "archived_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_closed", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 23, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "closed_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_created", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_harness", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 29, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "harness", + "seqno": 0 + }, + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_sessions_lease", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 21, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "lease_expires_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 17, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "state", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_owner", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "owner", + "seqno": 0 + }, + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "connection_id", + "id": 0, + "match": "NONE", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "seq": 0, + "table": "mcp_connections", + "to": "connection_id" + } + ] + }, + { + "name": "system_events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "code", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "severity", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "message", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "first_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "1", + "hidden": 0, + "name": "count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "resolved_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_system_events_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "code", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "tool_calls", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "tab_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "args_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "ok", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "error_code", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "error_message", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "result_text", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "result_size_bytes", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "span_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_tool_calls_error", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "error_code", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_session_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_tool_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "tool", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "vault_access", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "entry_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "handle", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "result", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "evaluate_enabled", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "page_url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "origin_check", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_vault_access_entry", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "entry_name", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_vault_access_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_vault_access_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "vault_bindings", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "handle", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "item_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "''", + "hidden": 0, + "name": "item_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "group_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "allowed_origins_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_principals_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_session_slugs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "allow_all_sessions", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": "0", + "hidden": 0, + "name": "redact_username", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "0", + "hidden": 0, + "name": "require_no_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": "0", + "hidden": 0, + "name": "dashboard_confirm", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": "1", + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_vault_bindings_group", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "group_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "handle", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "vault_group_policies", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "group_key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "group_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "access_mode", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "0", + "hidden": 0, + "name": "allow_all_sessions", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": "'[]'", + "hidden": 0, + "name": "session_slug_globs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_principals_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "0", + "hidden": 0, + "name": "dashboard_confirm", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "0", + "hidden": 0, + "name": "require_no_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "redact_username", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": "1", + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + } + ], + "triggers": [], + "views": [] +} diff --git a/packages/core/test/persistence/fixtures/v4.db b/packages/core/test/persistence/fixtures/v4.db new file mode 100644 index 0000000000000000000000000000000000000000..96aab5e4b9e1d2a9ab02520ccb2dceb783e8d3b7 GIT binary patch literal 348160 zcmeI5du$xXo#%T*4u?ZM+LA&~7=_V@vND{ABWffoe#KT8X>4&VQMN?IaSUIl+0){* zIWs-#?xCeP-Yg~CyBi0)huj5sIAn2m!2YoT77N@T0kXLJBYz}ua2Fh-gtH;ZI1 zFW)8s@)8aR4!B=c^`p9bW<=Y1n9_GFlhgICPyOog`&Cu*?2{)g&n%v|otEwu4=Z;m zn>HyQEfy6;8KVEs(f`5cEPWaFAD*D!8UH!+u@mzj_uZ4fhLi(e{O?&7Q2jacJFEU) zy{i67{rTvtW7~4S8L8*KIedAfGF;eH&ivc4`DFAv^LcH@UAe-;`!j3vR=aLqwCWeN zmeJ7+yWKVo&$8RDW_QeX!fnT~Gqt(JT5*2icEh$Hr)MZc$FbUm)zOmnyyO}<#dgvWwt%7t+z~`G7IXv zt(p!C9XfX$+p`V3sUZ!RoqTqF^DHdZ9;=-x1}Ip`sE0v9YP znDlj9OrRtWE=;a@A*`k(MKLD4=s}Z*KE-nj^G}|uanZWe5p5rN22#a*xIi`SWvi{Z zUBjT(BFPfZ=XI;ubxi(jtk_*o_=qnZ-F2V0ow{Z;^!Bn@CqK_#p}uChZdZDA%vEw` z-peH8UgRA}YZRc{W&mu*`ZR?XV%@W|43?t0=~1~8k}5S!NHmbFvGS3vBZc|B8HEdl zdx1pV)Tk&ne`eo838_jC>Sb*#SD2W{ygb2MnAgu0Kexo%Rk-mc^&#{g8U(an@A$L5Z6QE{kg z6SBf2$T3~J>lkKA%J^vFn7Y@$%bFKE7RBcSZjTO2qktHUdw19Un@0*qC;Byvz^WY* zzqhW9=W~T)rOeu?c;^lvXrVC4G~CU-dwDum*x3WG=`cYe6HG9^ILkAPlFJoJrOeAm zqk$kuB36=oOlV?WTy|uauq~uHE$$WI7&0e&QQ3h&UKnLES(0M5UAuh zni4F@Gm62)O7e&|$l7Q&S9pN3o$HgWdEUAx#m-$LPZm6tVRxx%8B`$VIY5k`92WDL z#l=|4RQfs+6Jg0iERI5AH&c992wkEp_!A)a;S*pmo6$6-cPx!dd@u#cc*3=cYf|Mr zEj?DVPFT0xPE)@W1vY41E6qsJz<#{;=-j!Ji^bA2&z9cSXv!k--jx_hnftZ-#g);a zTw%|i%*)U4HVviNjlN}LEgHG=1`QwkwdZ=+6B|eOY=^OD3rhM;Yr|E`F#Q&gafawv z1wFIPmlUFTnMv?U#p#1HRk0AE)*dv?bvyMz-c$po zTnw7(KD8-V7$489?d2`eqD2h*e>g^;xfG{YOd(k@#iF>XH@k_sJF)g%doGhJ%+6-6 z4Dl>nqhYpmt!2^l$(65^!%!^qlsA`vKv@V@2!H?xfB*=9 z00@8p2!H?xfB*=*V**<#g<;iI)E}w;P5pQD1s@Oq0T2KI5C8!X009sH0T2KI5CDPe zL11clMA@Gm-Q}Nct1LP8^K_6S^Xhkh+)(Ifkw<4q_YRFH6$;}Y46a!G1eJFw7U+YU zMwG)8sBL@JdCTCZ6DrGeII-i#!o4puqU@q@R-4Wy>&=)SJGYfl-&E)yJ|F-BAOHd& z00JNY0w4eaAOHd&00JAGz}-VtwMCa#>3&9?ZWR0X5voG}|E{8bccWJj_Gk9ghN%l|-p2PtF@l)vkuPN$lcMKy` z6a+v31V8`;KmY_l00ck)1V8`;HY$Ppw@ef!m04vwdx;!-ooJ=X4FJKr9q-YdWxIXI zs^8;ZFnH!!S^vMPs8=^?)lqH`009sH0T2KI5C8!X009sH0T2Lz-URYP%7m~Ii2eUx zDeA9!hlDE#fB*=900@8p2!H?xfB*=900@A<1|+a;s4&5g2FUvV*A?~a8?Y`YG6;YG z2!H?xfB*=900@8p2!H?x+(85$8XGT6WV6{_yA|E_5B@iFw^6CeH~llO{;$nn5B|PD z_0W9>4$^=A`~QBesK360fT5}&00JNY0w4eaAOHd&00JNY0w8eP6WBU5F)pkF%vd1K z|Nll&e{HptQ)NkBL2vAoL z009sH0T2KI5C8!X009sH0T9@L1Rl)q+&5k*(O>;ru2h#Dz3o+^9e&Tz4byGdor+#v zI%FKK-y7Zv_{o`1iT(dyDC#dZV7*af5C8!X009sH0T2KI5C8!X009sHfi!^~L;H5h z<^i%6Y^6{c+Wg1VboY4jwvEIr!m9_5K%L3_PE= zofWr3@6!t$T6W!R8cn@hH!B8xPad+b=*yJaEF~AdbnnLv4=}tyfEe3UB@t|9-I#+H~kY?e*fbFHCu+-imyt zUc}%3+pwaIf`b4EfB*=900@8p2!H?xfB*=9z#sx!hO#?_HG)haO#k1s?JLT*uc&7R zMS>p)fB*=900@8p2!H?xfB*=900`W40&BbW?AuvTl*!5b=)`C~pC46}i7b7T>^P~j zPg8;HX1!ANKlGjN^cxMwZdu({@VjNUZ0C~Ev|Y2FdG$-?>k3ngt>1<}fBENM%e?xZ zKR=^%UDL_@_Jt{?w^Gl$~fbu15(0s#;J0T2KI5C8!X009sH0T2KI5V)}f#)q=|(pG@Ghq5Jc>p!Lc zXVm|r&_8@Y00ck)1V8`;KmY_l00ck)1V8`;Har1g7GQ5PkPip2^Z&|S8@_U=00@8p z2!H?xfB*=900@8p2!H?xfWQC(;{AVbDmeW=01~`F00ck)1V8`;KmY_l00ck)1VCV; z5b)3c)1M58o&VoT<psn9=sKmY_l00ck)1V8`;KmY_l00ck)1a2XLPi8hN6XlXp zC}fp+vtq7RsxJM#LCbC*usdektj9EZec5b#((C?SnDPT_R-OKEVJz&_i(>!(s-j-K zg%w2BAOHd&00JNY0w4eaAOHd&00JNY0yls_erO^u3<+ZY|96V|yBm-Vk^}(|009sH z0T2KI5C8!X009sH0T8%#1o9cC|F6vFY2vM(>!3H5)T1lCld@QdhqCoU2zJt{hpX~jK6|$|b zTFbg;uB)8uD9*~y*#7?}^{*B6+w=n;5C8!X009sH0T2KI5C8!X009sHfm=>s_i!mY z8M6lnlR{w6I86Svl>f@l2ALOx{{JmSeM9}$Ew3O700JNY0w4eaAOHd&00JNY0w4ea zATW@?_Mz-#+#0ZNC|hGL>(T%JR#E>}{htFffF}rm00@8p2!H?xfB*=900@8p2!O!N zCr}tJWGDHhg}B;(V0bcH3+@T5Pyf%T|ESPEd_VvMKmY_l00ck)1V8`;KmY_l00cH5 zfxW}C*~!$~3gYGh@&5lWE9#dwUi2K&>Z5ib00JNY0w4eaAOHd&00JNY0w8eP5||p^ zn=P3a^;V~89x&`yU;Y1|74@HQTMDQc2!H?xfB*=900@8p2!H?xfB*>G0R$$8C$c5^ z%75(r0pk3BQ&F3D018wQ1V8`;KmY_l00ck)1V8`;KmY`80)e$1n`@=DorOJ{Hf^G} z_#LdC_+;--?9#q#x~^rn-PxGytZO)?*(Nv7ogI|cf#*8Qo}(M4)uOis&OX;Mm$S;I z*AG4*zyI&siu&!F003El00@8p2!H?xfB*=900@8p2!H?xyki1~>4kr_n3W(n@ZaCm zAol;iq^Mtd$JrwR5C8!X009sH0T2KI5C8!X009sHfww`x*j6qn6BE0X(TB$i3Y{2O z)w@lv*B|~{w!B7nN&IxXhCxRLg1-SM9NF#v*`~d0wL7}&Hf=+1GTVUty3qfBrl>!A z8wErZ5C8!X009sH0T2KI5C8!X009sHfp-;w!cev>H2%05LFoT)DC!&Us^X$BAOHd& z00JNY0w4eaAOHd&00JNY0_!5Mk8T5$Q?~)^*L&&zKUCBot}9Ul0s#;J0T2KI5C8!X z009sH0T2KI5O_Bd_yE2Bzbq~R^xyygf}(!m-B@^(2?Rg@1V8`;KmY_l00ck)1V8`; zZdU@Wu}VpKY=3DNyYat7SM^uSdapbG!3BT4?jA5N>a9-GJkU11*^YJ5Y$hB(B!_U! z3tiLom^HxtsyP4u6Gi>W?aCAN0s#;J0T2KI5C8!X009sH0T2LzJB7f+P_`oU{iI1j z=>LDOsDFQ_us}UQ00ck)1V8`;KmY_l00ck)1VG^SAyA^*|CQA3fA_1<|8Ji*MomBf z1V8`;KmY_l00ck)1V8`;K!EH2v19*(_W%D*QU4CiKmY_l00ck)1V8`;KmY_l00ck) z1U43d^6>s_CGie`LHhqoiu%&Vsv$}X0w4eaAOHd&00JNY0w4eaAOHdzjlc`7 zc14hwef0)5`zE;fB*=900@8p2!H?xfB*=900^v~0NekE{=a@+ z2nhlp00JNY0w4eaAOHd&00JNY0vnOQRw{Hx{gy)i@Bsl3009sH0T2KI5C8!X009sH z0T6hX6Zmi@uS^tn@7kwi&5GTrRQbCBJ=1AfZF(D^Yq~DIJr)ZUZzFasU6S zqF#NMR}jSm0T2KI5C8!X009sH0T2KI5C8!Xc#8z`Llb#nCV>9`7SSO%2!H?xfB*=9 z00@8p2!H?xfB*<=7y|jBY+l~~|FWWfdBYSQ1qA^R009sH0T2KI5C8!X009sHf!mkB z$G1-?6MH6<2eubRNAsikYzdZm+g-g$M*w>4|Nohy{@KQ=DM||hAOHd&00JNY0w4eaAOHd&00JAAzy)<@VfSIB z^iVmgDA~Ny_TS^Ld!E_$=mq|B!Iz?AUg(;xXV%>Yz3yMHE*&xs*YBP6U-a+J#+>!a zdF!Irb=pQ~H00JNY0w4eaAOHd&00JNY0w4eax0t}kGnjO@Sia4d@il>sHgOzc9rf zrXHMX+RN_Li$e1kKgIt4?-ccSx444H9t1!D1V8`;KmY_l00ck)1V8`;K;Zfk7#rG^ z_iYG5|Npt7{`~r8fz&_%1V8`;KmY_l00ck)1V8`;KmY`84S}6Qm3;EdfH?pEHAVf} zttk_71pyEM0T2KI5C8!X009sH0T2KI5V$shM>3;I;qiUhQN?p~!wg^lZ#V0es{fhg zUI#vHb!1oh{Qql;`r5VgLI@B50T2KI5C8!X009sH0T2KI5CDN&O5pyHiNfJ>)~cH= z|4#-!ar*3HG5T|WOWo$m3sd%rX4u`fH}zoEe+vNH|5xt1rDaFvAOHd&00JNY0w4ea zAOHd&00JNY0ymfdyZ;aU{{}-w@*n^LAOHd&00JNY0w4eaAOHd&aEl2*|G&izfb2m4 z1V8`;KmY_l00ck)1V8`;K;R}2fc}3IAR`M9009sH0T2KI5C8!X009sH0T8&w1fc)l z;s!wWAOHd&00JNY0w4eaAOHd&00JOz69_QUIHo3^1h*&qLNzuK-;vHyQNG%V@>0w4eaAOHd&00JNY0w4eaAOHe` z35fmw-ze&D28V+$2!H?xfB*=900@8p2!H?xfB*=9z=k5QWhlFo?f*9vL+t-=QopIF z-&DW9p=yD`f&d7B00@8p2!H?xfB*=900@A<9Y^5qVI{jS^Xiw(*A=^2uT=fd)bQSH z$!O}`x>+&ox+%1Nw_$fGdUffLakzf(*0IkhW1H?#@^9p8qyIYhy%8<PZ%^k1JP?{^|rFc@zaaC`2P3<|?Zm0daox1Lsbxrq*^9zf$$7*N7Qn08`oS8p0 zcjoEh7a%N5CB(3^IZB_3!J#C4e zt@g63EjxC%6L)*Whcv%%y!MG=zc{SA@p#ibEn-)G<=8{HLb;sz{2XtKc(C}>!?6~L z`|(C;=Hg#bYOG#~v?E^U5V76{io*RLf;ndB?2lhNpF1)8R;y zQkqUN9V3?9g|6k8THB@$PW^&}e;tK%%wDmiWb3-ySh97et{Hawyydjkk!Y_tQj)8t z!|?Y{sLJpdjyb6R4(Q&=zWd^Xxx&#(W^Gs4ckPbp=$`Fp)BscxSM#ivnRNU}s{1BG z@Mx_6COxKY!{|EHTr~QWFCV-=SEy7nFYV-=I2kti{9vpjCw+NWcFhYRJgFZYe#O{t$TM+s0^ zm0&m-D<6Geq;R&lAHM)a zWedjcR7f5_*7`vj)4>2^)n{njH(MQ>hFIg0#%7k{QzvK!Ogb0m&xVb(cId;o!eg_U zwHF2yzG1l}z?V{`y1&2F2F26-xV_8H8g@qNeKRyrabQ&6pzyAzdtJBqkw=Q9j>#v$ z(%OzAqRfv(8(FE=DG+y<}gEInAYt7W3A43|6j~b7GAnLGlH% zZn-VXb^R3+Nh2-St{i%GER zvg=}<$uf_mN{n>U;??(ZrLHb7T+i0TdY4y0ca}{rT=V$jXKZ|C!J-yP4T#K}WdI#V}DuW+>%DQ5A>Bxq$h0B`J;-tOv6YRzzYMc*CsIg^%B=j2^d9jiF2CNa&>(XnLGaW-J<;`9n zZY8bvuFO4(x^vrq+4{lFXU1L|dvf&OjNP64{YW$So#D@o92wr7xo7Be8^Hi@qssV_ zq2>zv_hC-qDvV zvbfNY8tIfQioTpmoDF!*a7t+CPMe0o@Y(8wUzv8O@dQFApPfHZ_b_cVb=RX>n(cnJ z52I-Z^SnQ6(1K4?H<}h_iqG55irdi*F*MH<{bFij1{38e@|wh*f^bY>q$!40+M$X) zQ*6;h6g?-bENeUNA~VbW%-Vdo8cIly#ui-(w;ibkQ6d13#8yNJ_i33_utt;Mh((aK zjb_ljnYCP4CT4>OtY}Pgl%DTOl|}Ll^+m}orYc^U-okBfuM~JMlY&WKx5avw%NJv!zpIWzBN>d|DF3cQ1^8wKdL8317r^80wtb>!eK^)^)0BU?ub^LzWLs8XwF zRp~*!tc~Rg6BC)2CwL3<`nlrgmRP$AH-D8SS?6TKB^z7>zy?`qc!!L}#7NWU`3LAy zCOThs5u#YvcJ`;TWl3r{bO4r{zXAMwy1Yxpyy5$6|v{EM`m%3x*tD zoOy+P#Y@R?#p~sx(LfNS9!oDD6PlP8mmS$<-BWEOM3QiX-D+Lt`gMrqHKafun|o?R z*A|oH%qa~5GV@LxaP6c9~1H|{qVKI+cTzsWW zrLQ9~(Um;J3Na*hGsQM-)eIC-?!%Xr!7N78lism3F6qJ4BjX7dY_3U_^R)CB&Bj7f z;yX=UvN;&E!jV=?QD02%gixY`4CLwV!LsAMDlvkJ@tRM$(wLhZSFel??YSQIft`-AXRB)ZO$+2z%P{>Gka33SSOwFzhgO%C z$zmm<1#OxdXwoWnpJYWa$C^D83q1<``Cr9#h%E$YbTL+a6K2XI@q|{o&UCwB>VSSI7SA z*uA4a$^E~PACLUy$V1t04*#|KueNXG;nhDICy@- z7qw_>Ch!SDvRNx|oAzVNTN>rH{3)Sl-WN`l3DuD6&k48Fsf-f=cqEp2!ab&EQb9t) z30qdbv|rfR|74tZHQ7?`#vWi}XB$vN*H=ProsE5_IA9EqW(?NS-FEu;Nf@FUEG|U2 zM;%wW&J!4#6suvECX0j{So_%b$5Z`R4#6agJxe<}Geyr07P(Pf<*nM~6Lda@GM$WP zN`^?oWQqMj?<~20m?3{+Rq}V@f@e9SqmPXg_Ks)HCGv4uCi8tVaibjE^5yYI`J&;a ztz2+eJof*In1C=BE)?rIVbDi#Xp_k|ZH7}UYh|(%c6%KuYPrktl?V$Q5(uoWWQHUw zkvMR`1u5K;mR8x~P>&QUTylni&Rb@)F0HP|%4_FqyhC07Ea#Hgt#^~pkHvT;eL1_J z=O!&RVQGSj#Q5~Lqjbo?-tUmxfTd=#e_Ve2IJe-xbc$D0cW9THoj)S`4ec-|9v_WW zGvUc=DWb8eTx&VEo^?HMaJHDI3Yl#>oherKqDAW#+tFRpjg@XEP_%R}Veev6A@$Kf zaLhKHT_D?{q}ar;v%TY1r-vwqgY-d`GFO}U-ZB>@5}V;;q->Mn=t$TjW5+4E$vNgy zOZC#2cW9%IxlXeqtHHRyhVSO8Ul(B~dI#q{uWX+aCMGeH#IlV&9E-_9%xm3aMQpb_ zYyjyr602u=9B?U~V|j^_pTVpd`f|10YM-}BOrEF3BK;EQ0K`^*i}qGXTTCb4)9g!U z+^(^Ga!t$BwWfVBf=P`TSfFtBmIlw%%)YkkXzty6Murrkr*6O*nYBkx@=9NhRFs%D zAPyYIoEB0eOw3!vU~7Vyvwzx{z{|_pV9be5ct{7-igOFcqXQ{oB;s!I6Dh)DZTI73 z4W*IN3`fdX{+hp*jyayXMpgVc>B`8HY_IzCDk0nd->&?TqWqEi=j!yfuWoy2>sPkk zxA}{k8=LPQ`^wlt{{QB#=2mk%M*ix?D8HZIw>?*woXq_DNj@_N%j@9x&iME%&MpVb zwAh*_x#D04RnnuL{e0VW(#~lESxiqA5hN`jspG~%%ld$TTFZ@yxp zBcU|YRoMB=pnfFr%%1pG4u$uKA{ASl2BRwj%u3n6_Mvi{3@;@`mDiiJk_(oQAwnO0 zvw~}n*bv5*zz}A&a$)btE^6aZpbuEo%D|MHbU4zcuG8wD6o_e^O6oyi1m%+ zNTm)h?f6aR2lstAC3g6QYAhN3uMv7p6Yw?lV-=t%(3*s+9= zneGtpU>zM5O>B)g(XkvqZe&pA6=1g@mLoX`X^e#jWO(b7t%5GxkY*+IkwB^?k3hzX zOOE|KJL?k|$(aH}ldyaTsj-Jtjy;*Rp>QJgY&wWUf69h-75P_>&V&apds9C0<9R;oM#YT26=O56+ebC=lWti6L4AH;Nbx>_XYWDDmyult?> zx%d~ef}qoU<=vYl-Ew03{|@CZ6y+~Q--}54vE^Kzj0896)9QdJ68VX+teqpb>1c+R_qrli zS=Lu)XwQ6KW-Ssoq1pLYbbQ#d+ksSZWZz(GjulCEPj0|Hx1ahL=5VJE+Z`f9f6#Ew51`HEe*G2`D+*ZLX~d~GSX%q|6#mZ%+?1GTQN zw8iS_7n4pd87sZ37Yo5F$&P_heOOiKm%CzhI$`-`%OX#Y(d)T`&|}S-TzWcXGVaDs zh(Q9(Np_I3i}F~Rw7Zqu%$i;iji9685^RXyNBk(LS}6| zkzOE7e!3s*lWr6&Ds14EvJqT1_T~zOLgwXtyvy*?@E`m9)cyD0ux1$CLAU^fgLz^+ zlC0`}QPyD{kTm|-xYu(ql&*ELQH!@{yb!E_fh@#*g4rPS6+P)@HVt4HLA5^B_ z=lP90+iQ^FPIRPoi1o{74)epNpF7Jb2ilAlysseTa3n@Q<;`0ndTD{EO=1>a&#)lh zD;IM-yU|9kK=G>^x*$Y)#B~yOU3#x+9G%u1xyd69Skq`IH(2u~z$7G~w-;WRM-a(ONG!h5F{hkp# z23a2Jo24J2XO>YnBt>bG`fx%WaGKPw;;o6-weg{k_^w!7zV59^5qK$_|dSJ z#mU@c;N2`1&pAPw1(>J@tsd*D%tN~F8;(F>7v?VmB7-u$ zhQnV)`Rs$?KQ_RmH(8V2#;MRA2Pu2e)hSuKA{~H+Fxh1`)xxr`2+yQScAbs}LM~DN EAAeuiT>t<8 literal 0 HcmV?d00001 diff --git a/packages/core/test/persistence/harness-migration.test.ts b/packages/core/test/persistence/harness-migration.test.ts index 9894906..f9dda7b 100644 --- a/packages/core/test/persistence/harness-migration.test.ts +++ b/packages/core/test/persistence/harness-migration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'bun:test'; import { copyFileSync } from 'node:fs'; import { join } from 'node:path'; +import { SCHEMA_VERSION } from '../../src/infra/persistence/migrations/index.ts'; import { openDatabase } from '../../src/infra/persistence/open.ts'; import { SqliteUnitOfWork } from '../../src/infra/persistence/unit-of-work.ts'; import { FakeClock, FakeLogger, tempDir } from './helpers.ts'; @@ -33,7 +34,8 @@ describe('migration 0003-harness-identity', () => { it('upgrades a v2 database: old sessions read unknown and stay listed', async () => { const { dir, handle, uow } = await openV2Copy(); try { - expect(handle.schemaVersion).toBe(3); + // Upgrades through v3 to head. + expect(handle.schemaVersion).toBe(SCHEMA_VERSION); const page = await uow.repos.sessions.list({ limit: 50 }); expect(page.items.length).toBeGreaterThan(0); for (const row of page.items) expect(row.harness).toBeNull(); diff --git a/packages/core/test/persistence/helpers.ts b/packages/core/test/persistence/helpers.ts index 4d1219c..2948b92 100644 --- a/packages/core/test/persistence/helpers.ts +++ b/packages/core/test/persistence/helpers.ts @@ -101,6 +101,8 @@ export function sessionRecord(overrides: Partial = {}): SessionRe launchMs: 500, config: { channel: 'chromium', headless: true }, harness: null, + sandboxed: null, + browserVersion: null, ...overrides, }; } diff --git a/packages/core/test/persistence/session-browser-migration.test.ts b/packages/core/test/persistence/session-browser-migration.test.ts new file mode 100644 index 0000000..06087dd --- /dev/null +++ b/packages/core/test/persistence/session-browser-migration.test.ts @@ -0,0 +1,110 @@ +/** @module test/persistence/session-browser-migration.test — schema v4 (`0004-session-browser`) over a v3 database: additive, nothing backfilled, old sessions serve no `browser` ("not recorded"), and the columns round-trip (03 §7, D-31). */ + +import { describe, expect, it } from 'bun:test'; +import { copyFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SCHEMA_VERSION } from '../../src/infra/persistence/migrations/index.ts'; +import { openDatabase } from '../../src/infra/persistence/open.ts'; +import { SqliteUnitOfWork } from '../../src/infra/persistence/unit-of-work.ts'; +import { sessionRowToSummary } from '../../src/interface/http/serializers/sessions.ts'; +import { FakeClock, FakeLogger, sessionRecord, tempDir } from './helpers.ts'; + +const FIXTURES = join(import.meta.dir, 'fixtures'); + +async function openCopy(fixture: string) { + const dir = tempDir(); + const path = join(dir.path, 'browserhive.db'); + copyFileSync(join(FIXTURES, fixture), path); + const handle = await openDatabase({ + path, + dataDir: dir.path, + appVersion: 'test', + clock: new FakeClock(), + logger: new FakeLogger(), + }); + return { dir, handle, uow: new SqliteUnitOfWork(handle.db) }; +} + +describe('migration 0004-session-browser', () => { + it('upgrades a v3 database: existing sessions stay unrecorded and serve no browser', async () => { + const { dir, handle, uow } = await openCopy('v3.db'); + try { + expect(handle.schemaVersion).toBe(SCHEMA_VERSION); + const raw = handle.raw + .query<{ sandboxed: number | null; browser_version: string | null }, []>( + 'SELECT sandboxed, browser_version FROM sessions', + ) + .all(); + expect(raw.length).toBeGreaterThan(0); + for (const row of raw) { + expect(row.sandboxed).toBeNull(); + expect(row.browser_version).toBeNull(); + } + const page = await uow.repos.sessions.list({ limit: 50 }); + expect(page.items.length).toBe(raw.length); + for (const row of page.items) { + expect(row.sandboxed).toBeNull(); + expect(row.browserVersion).toBeNull(); + expect('browser' in sessionRowToSummary(row, 1_700_000_000_000, false)).toBe(false); + const detail = await uow.repos.sessions.get(row.sessionId); + expect(detail?.sandboxed).toBeNull(); + } + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('round-trips the launch record through insert and update, and a patch without it keeps it', async () => { + const { dir, handle, uow } = await openCopy('v3.db'); + try { + await uow.transaction(async (r) => { + await r.sessions.insert(sessionRecord({ sessionId: 'rec-22222222', state: 'reserved' })); + }); + expect((await uow.repos.sessions.get('rec-22222222'))?.sandboxed).toBeNull(); + await uow.repos.sessions.update('rec-22222222', { + state: 'live', + sandboxed: false, + browserVersion: '153.0.8010.12', + }); + await uow.repos.sessions.update('rec-22222222', { state: 'closed', closedAt: 1 }); + const row = await uow.repos.sessions.get('rec-22222222'); + expect(row?.sandboxed).toBe(false); + expect(row?.browserVersion).toBe('153.0.8010.12'); + if (row === null) throw new Error('row missing'); + expect(sessionRowToSummary(row, 2, false).browser).toEqual({ + version: '153.0.8010.12', + sandboxed: false, + }); + // A persistent context records its verdict without a version. + await uow.repos.sessions.update('rec-22222222', { sandboxed: true, browserVersion: null }); + const persistent = await uow.repos.sessions.get('rec-22222222'); + if (persistent === null) throw new Error('row missing'); + expect(sessionRowToSummary(persistent, 2, false).browser).toEqual({ + version: null, + sandboxed: true, + }); + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('refuses a sandboxed value other than 0 or 1', async () => { + const { dir, handle } = await openCopy('v4.db'); + try { + expect(() => + handle.raw.exec("UPDATE sessions SET sandboxed = 2 WHERE session_id = 'shop-a1b2c3d4'"), + ).toThrow(); + const row = handle.raw + .query<{ sandboxed: number | null; browser_version: string | null }, [string]>( + 'SELECT sandboxed, browser_version FROM sessions WHERE session_id = ?', + ) + .get('shop-a1b2c3d4'); + expect(row).toEqual({ sandboxed: 1, browser_version: '154.0.8037.57' }); + } finally { + await handle.close(); + dir.dispose(); + } + }); +}); From 1fa13acee9298c685fb87a322614a98ad3ab5faf Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Sat, 26 Sep 2026 22:28:13 -0400 Subject: [PATCH 3/5] feat(dashboard): show a session's sandbox state in Details for closed sessions too The Session panel's Sandbox row now shows for every session: a sandboxed pill or muted "not sandboxed" from SessionSummary.browser (live or recorded at launch), muted "not launched" when the browser never started, and muted "not recorded" with an explainer for sessions from before the record existed. It never reads "not sandboxed" without a record. --- .../features/sessions/SessionPage.test.tsx | 25 +++++++++++ .../features/sessions/detail/DetailsPanel.tsx | 41 ++++++++++++------- .../features/sessions/session-format.test.ts | 15 +++++++ .../src/features/sessions/session-format.ts | 25 +++++++++++ packages/dashboard/src/lib/links.ts | 1 + 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/dashboard/src/features/sessions/SessionPage.test.tsx b/packages/dashboard/src/features/sessions/SessionPage.test.tsx index 9de12e6..5aa594f 100644 --- a/packages/dashboard/src/features/sessions/SessionPage.test.tsx +++ b/packages/dashboard/src/features/sessions/SessionPage.test.tsx @@ -194,6 +194,31 @@ describe('SessionPage', () => { expect(screen.getByText('Identity & coherence')).toBeDefined(); }); + it('shows the sandbox of a closed session as recorded, and "not recorded" without a record', async () => { + const closed = { + live: false, + state: 'closed', + closed_at: 1_000, + closed_reason: 'user', + } as const; + const recorded = mount(`/sessions/${ID}?tab=details`, { + detail: sessionDetail({ ...closed, browser: { version: '154.0.8037.57', sandboxed: true } }), + }); + expect(await screen.findByText('chromium 154.0.8037.57 · headless')).toBeDefined(); + expect(screen.getByText('sandboxed')).toBeDefined(); + recorded.unmount(); + const off = mount(`/sessions/${ID}?tab=details`, { + detail: sessionDetail({ ...closed, browser: { version: '153.0.8010.12', sandboxed: false } }), + }); + expect(await screen.findByText('not sandboxed')).toBeDefined(); + off.unmount(); + mount(`/sessions/${ID}?tab=details`, { detail: sessionDetail(closed) }); + expect(await screen.findByText('not recorded')).toBeDefined(); + expect(screen.queryByText('not sandboxed')).toBeNull(); + // The explainer (popover) says why; its content is covered by the visual check. + expect(screen.getByRole('button', { name: 'Why the sandbox is not recorded' })).toBeDefined(); + }); + it('shows a page-level "Session not found" for 404', async () => { renderPage({ path: '/sessions/$id', diff --git a/packages/dashboard/src/features/sessions/detail/DetailsPanel.tsx b/packages/dashboard/src/features/sessions/detail/DetailsPanel.tsx index c849604..a94a007 100644 --- a/packages/dashboard/src/features/sessions/detail/DetailsPanel.tsx +++ b/packages/dashboard/src/features/sessions/detail/DetailsPanel.tsx @@ -1,6 +1,7 @@ -/** @module features/sessions/detail/DetailsPanel — Details tab: a compact counts strip that links into Activity with filters, the session record (owner, browser, persistence, lease, lifetime, URL), the self-reported client (harness, model, workspace, meta), identity & coherence, and "Resize the agent's browser" while live; zero-noise counts (vault fills only with the vault on) and a worded closed reason */ +/** @module features/sessions/detail/DetailsPanel — Details tab: a compact counts strip that links into Activity with filters, the session record (owner, browser, sandbox — live or recorded at launch, persistence, lease, lifetime, URL), the self-reported client (harness, model, workspace, meta), identity & coherence, and "Resize the agent's browser" while live; zero-noise counts (vault fills only with the vault on) and a worded closed reason */ import type { SessionDetail, TimelineKind } from '@browserhive/contracts/http'; import { Link } from '@tanstack/react-router'; +import { InfoDot } from '@/components/shared/InfoDot.tsx'; import { KeyValue, type KeyValueItem } from '@/components/shared/KeyValue.tsx'; import { LeaseBar } from '@/components/shared/lease-bar.tsx'; import { RelativeTime } from '@/components/shared/RelativeTime.tsx'; @@ -15,7 +16,7 @@ import { sessionDisplayState } from '@/lib/status-registry.ts'; import { cn } from '@/lib/utils.ts'; import { ClientPanel } from '../../harness/ClientPanel.tsx'; import { useSessionMutations, useVaultEnabled } from '../api.ts'; -import { closedReasonNote } from '../session-format.ts'; +import { closedReasonNote, sandboxRecord } from '../session-format.ts'; import { IdentityCard } from './IdentityCard.tsx'; import { ViewportForm } from './ViewportForm.tsx'; @@ -100,6 +101,27 @@ function CountsStrip({ ); } +/** The Sandbox row: a success pill, or a muted word that never reads as "no" without a record (D-31). */ +function SandboxValue({ session }: { readonly session: SessionDetail['session'] }) { + const record = sandboxRecord(session); + if (record === 'sandboxed') { + return ; + } + if (record !== 'not recorded') return {record}; + return ( + + not recorded + +

Recorded for sessions launched with BrowserHive 0.2 or later.

+

+ This session started before that, so whether its browser ran inside Chromium's sandbox is + unknown. It does not mean the sandbox was off. +

+
+
+ ); +} + function SessionRecord({ detail }: { readonly detail: SessionDetail }) { const { session } = detail; const now = useServerNow(); @@ -132,19 +154,8 @@ function SessionRecord({ detail }: { readonly detail: SessionDetail }) { .filter((v) => v !== null) .join(' · '), }, - // Live sessions only: whether Chromium's sandbox is on for this browser process. - ...(session.browser !== undefined - ? [ - { - key: 'Sandbox', - value: session.browser.sandboxed ? ( - - ) : ( - not sandboxed - ), - }, - ] - : []), + // Whether Chromium's sandbox was on for this session's browser: live, or as recorded at launch. + { key: 'Sandbox', value: }, { key: 'Persistence', value: session.persistence_mode }, { key: 'Started', value: }, ); diff --git a/packages/dashboard/src/features/sessions/session-format.test.ts b/packages/dashboard/src/features/sessions/session-format.test.ts index 92cef0b..3c12dfe 100644 --- a/packages/dashboard/src/features/sessions/session-format.test.ts +++ b/packages/dashboard/src/features/sessions/session-format.test.ts @@ -5,10 +5,25 @@ import { closedReasonNote, closedReasonText, coarseDuration, + sandboxRecord, sessionEnded, } from './session-format.ts'; describe('session format', () => { + it('words the sandbox record, never "not sandboxed" without one (D-31)', () => { + const closed = { state: 'closed', closed_reason: 'user' }; + expect(sandboxRecord({ ...closed, browser: { sandboxed: true } })).toBe('sandboxed'); + expect(sandboxRecord({ ...closed, browser: { sandboxed: false } })).toBe('not sandboxed'); + expect( + sandboxRecord({ state: 'live', closed_reason: null, browser: { sandboxed: true } }), + ).toBe('sandboxed'); + expect(sandboxRecord(closed)).toBe('not recorded'); + expect(sandboxRecord({ state: 'crashed', closed_reason: 'crash' })).toBe('not recorded'); + expect(sandboxRecord({ state: 'closed', closed_reason: 'launch_failed' })).toBe('not launched'); + expect(sandboxRecord({ state: 'launching', closed_reason: null })).toBe('not launched'); + expect(sandboxRecord({ state: 'reserved', closed_reason: null })).toBe('not launched'); + }); + it('says who or what closed a session', () => { expect(closedReasonText('user')).toBe('Closed by the agent'); expect(closedReasonText('operator')).toBe('Closed from the dashboard'); diff --git a/packages/dashboard/src/features/sessions/session-format.ts b/packages/dashboard/src/features/sessions/session-format.ts index 927a5ae..950bec7 100644 --- a/packages/dashboard/src/features/sessions/session-format.ts +++ b/packages/dashboard/src/features/sessions/session-format.ts @@ -13,6 +13,31 @@ export function browserLabel(session: { return parts.length === 0 ? null : parts.join(' · '); } +/** What the Details panel says about Chromium's sandbox for one session (D-31). */ +export type SandboxRecord = 'sandboxed' | 'not sandboxed' | 'not launched' | 'not recorded'; + +/** + * The session's sandbox state as recorded: from `browser` (live, or recorded at launch) when present; + * without it, "not launched" when the browser never started, else "not recorded" (a session from + * before the record existed). Never "not sandboxed" without a record. + */ +export function sandboxRecord(session: { + readonly browser?: { readonly sandboxed: boolean } | undefined; + readonly state: string; + readonly closed_reason: string | null; +}): SandboxRecord { + if (session.browser !== undefined) + return session.browser.sandboxed ? 'sandboxed' : 'not sandboxed'; + if ( + session.state === 'reserved' || + session.state === 'launching' || + session.closed_reason === 'launch_failed' + ) { + return 'not launched'; + } + return 'not recorded'; +} + /** Why a session closed, as a sentence fragment ("Closed by the agent"). */ export function closedReasonText(reason: string | null): string { switch (reason) { diff --git a/packages/dashboard/src/lib/links.ts b/packages/dashboard/src/lib/links.ts index c3998ed..f0232fc 100644 --- a/packages/dashboard/src/lib/links.ts +++ b/packages/dashboard/src/lib/links.ts @@ -47,6 +47,7 @@ export const DOCS_PAGES = { stealthLevels: { page: 'guide/stealth', anchor: 'levels' }, security: { page: 'guide/security' }, securityRecorded: { page: 'guide/security', anchor: 'what-is-recorded' }, + securitySandbox: { page: 'guide/security', anchor: 'the-browser-sandbox' }, securityBlocklist: { page: 'guide/security', anchor: 'the-blocklist-is-not-an-egress-firewall' }, securityAuth: { page: 'guide/security', anchor: 'authentication' }, telemetry: { page: 'guide/telemetry' }, From f304b9772b97312ff5132a4cd72813d0313a4580 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Sat, 26 Sep 2026 22:28:18 -0400 Subject: [PATCH 4/5] docs(sessions): a persistent context does report its browser version Measured on the real run: Chromium's persistent context exposes a Browser, so its version is recorded like any other. The spec, the contract comment and the port comment said it was always null; they now say null only when no Browser can be read. --- packages/contracts/src/http/sessions.ts | 2 +- packages/core/src/app/sessions/metadata.test.ts | 2 +- packages/core/src/ports/browser-driver.ts | 2 +- packages/core/src/ports/persistence/records.ts | 2 +- .../core/test/persistence/session-browser-migration.test.ts | 2 +- specs/00-decisions.md | 2 +- specs/03-admin-backend.md | 4 ++-- specs/09-testing.md | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/contracts/src/http/sessions.ts b/packages/contracts/src/http/sessions.ts index 7863fef..29fc46c 100644 --- a/packages/contracts/src/http/sessions.ts +++ b/packages/contracts/src/http/sessions.ts @@ -103,7 +103,7 @@ export const SessionSummary = z.object({ stealth_recorded: z.boolean(), identity: AppliedIdentity.nullable(), /** - * The browser's real version (null for a persistent context) and whether it runs sandboxed (D-31): + * The browser's real version (null when the driver cannot read it) and whether it runs sandboxed (D-31): * from the live browser while it runs, else as recorded at launch (schema v4). Absent = not recorded * (sessions from before schema v4, or whose browser never launched), never "not sandboxed". */ diff --git a/packages/core/src/app/sessions/metadata.test.ts b/packages/core/src/app/sessions/metadata.test.ts index e21c4cd..1c3f6e1 100644 --- a/packages/core/src/app/sessions/metadata.test.ts +++ b/packages/core/src/app/sessions/metadata.test.ts @@ -194,7 +194,7 @@ describe('session metadata projections', () => { }); }); - it('a persistent context records its verdict with a null version', () => { + it('a launch without a readable version records its verdict with a null version', () => { const { session } = launched({ version: null, sandboxed: true }); expect(toSessionPatch(session)).toMatchObject({ sandboxed: true, browserVersion: null }); expect(toSessionSummary(session, T0 + 3).browser).toEqual({ version: null, sandboxed: true }); diff --git a/packages/core/src/ports/browser-driver.ts b/packages/core/src/ports/browser-driver.ts index e78feca..edeeb5d 100644 --- a/packages/core/src/ports/browser-driver.ts +++ b/packages/core/src/ports/browser-driver.ts @@ -198,7 +198,7 @@ export interface SessionHandle { readonly driver: 'patchright' | 'playwright'; /** The identity applied after launch, or `null` when stealth is off or `applyIdentity` failed. */ readonly identity: AppliedIdentity | null; - /** The engine's real version (null for a persistent context) and whether it runs sandboxed. Absent in fakes. */ + /** The engine's real version (null when no `Browser` can be read; Chromium persistent contexts expose one) and whether it runs sandboxed. Absent in fakes. */ readonly browserInfo?: SessionBrowserInfo; readonly warnings: readonly LaunchWarning[]; readonly tracing: TracingHandle | null; diff --git a/packages/core/src/ports/persistence/records.ts b/packages/core/src/ports/persistence/records.ts index 1b73f1f..1089fbc 100644 --- a/packages/core/src/ports/persistence/records.ts +++ b/packages/core/src/ports/persistence/records.ts @@ -100,7 +100,7 @@ export interface SessionRecord { * `null` = not recorded (created before schema v4, or the browser never launched). */ readonly sandboxed: boolean | null; - /** The launched browser's real version (D-31); `null` when not recorded or for a persistent context. */ + /** The launched browser's real version (D-31); `null` when not recorded or not readable. */ readonly browserVersion: string | null; } diff --git a/packages/core/test/persistence/session-browser-migration.test.ts b/packages/core/test/persistence/session-browser-migration.test.ts index 06087dd..0d0e426 100644 --- a/packages/core/test/persistence/session-browser-migration.test.ts +++ b/packages/core/test/persistence/session-browser-migration.test.ts @@ -76,7 +76,7 @@ describe('migration 0004-session-browser', () => { version: '153.0.8010.12', sandboxed: false, }); - // A persistent context records its verdict without a version. + // A launch without a readable version records its verdict alone. await uow.repos.sessions.update('rec-22222222', { sandboxed: true, browserVersion: null }); const persistent = await uow.repos.sessions.get('rec-22222222'); if (persistent === null) throw new Error('row missing'); diff --git a/specs/00-decisions.md b/specs/00-decisions.md index 4f0fb6f..36212e5 100644 --- a/specs/00-decisions.md +++ b/specs/00-decisions.md @@ -622,7 +622,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Decision.** - Schema v4 (`0004-session-browser`, `compatible: true`) adds two nullable columns: `sessions.sandboxed` (`INTEGER CHECK IN (0,1)`) and `sessions.browser_version` (`TEXT`). Nothing is backfilled: a guess would be worse than no answer. - The session aggregate keeps the launch facts it attached (`browserInfo`: the verdict the sandbox policy applied and `Browser.version()`), including after teardown. They are written with the `session.updated` patch once the browser has launched (first when the session becomes live) and never cleared. If a session's browser is launched again, the latest launch wins, since that is what the session ran with last; today no path relaunches a session's browser. -- `SessionSummary.browser` keeps its shape (`{version, sandboxed}`, optional). Every builder applies the same order: the live handle, then the recorded launch (in memory or from the row, when `sandboxed` is not NULL), else absent. A persistent context has no `Browser` object, so its version stays `null` while its verdict is recorded. +- `SessionSummary.browser` keeps its shape (`{version, sandboxed}`, optional). Every builder applies the same order: the live handle, then the recorded launch (in memory or from the row, when `sandboxed` is not NULL), else absent. When the driver has no `Browser` object to ask, the version is `null` while the verdict is still recorded (Chromium persistent contexts do expose one: measured, the version is recorded). - Absent means "not recorded" (sessions from before v4, sessions whose browser never launched, drivers that report no launch facts). No surface shows it as "no" or "not sandboxed"; the dashboard distinguishes "not launched" where the state says so. - The MCP tool surface is unchanged (D-12): the record is on the admin API and the dashboard only. diff --git a/specs/03-admin-backend.md b/specs/03-admin-backend.md index 7be06d0..0eb4920 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -180,7 +180,7 @@ Conventions (§5) apply to every list. `Auth` column: **S** operator session or ### 4.2 Sessions -`SessionSummary` (the one shape used by lists, detail, and WS): `session_id, slug, owner, tenant_id, channel, engine, headless, incognito, persistence_mode, current_url, created_at, closed_at, closed_reason, archived_at, lease_expires_at, lease_paused_at, lease_remaining_ms, state ('reserved'|'launching'|'live'|'paused'|'draining'|'closed'|'crashed'), live, disable_evaluate, vault_enabled, stealth, fingerprint, humanize, stealth_recorded, identity, browser?:{version, sandboxed}, proxy_label, counts:{tool_calls, errors, pages, blocked, attention_open, vault_access}, has_live_viewers, harness, client:{name, version, agent_name?, model?, harness?, harness_label?, harness_source?, model_source?, workspace?, title?, protocol_version?, meta?}` — `browser` is the browser's real version (`null` for a persistent context, where Playwright exposes no `Browser`) and whether it runs inside Chromium's sandbox (D-27, D-31): for a session whose browser is running it is read from the live handle; otherwise (closed, crashed, draining after teardown, or served from the stored row) it is the value recorded at launch (`sessions.sandboxed`, `sessions.browser_version`, schema v4). It is **absent** when nothing was recorded: sessions created before schema v4, sessions whose browser never launched (`reserved`, `launching`, `closed_reason = 'launch_failed'`) and drivers that report no launch facts. Absent means "not recorded", never "not sandboxed"; clients MUST NOT read it as `sandboxed: false`; `harness` is the harness that launched the session (02 §1.4), fixed at launch (`sessions.harness`), always present: a slug, or `unknown` for sessions whose launch identified nothing and for sessions created before schema v3. `client` is the launching MCP connection's self-reported identity (02 §1.4): `name`/`version`/`title` from `clientInfo`, `protocol_version`, `harness` (slug) with `harness_label` and `harness_source`, `model` with `model_source`, `workspace` (also as `agent_name`, kept for compatibility) and the capped `meta` bag; absent values are omitted. For a live session it is the identity resolved when it launched; for a stored session, the connection row's latest resolution. It is `null` when the session has no connection row, or when the client reported nothing at all (no name, version, workspace or model, and harness `unknown`). +`SessionSummary` (the one shape used by lists, detail, and WS): `session_id, slug, owner, tenant_id, channel, engine, headless, incognito, persistence_mode, current_url, created_at, closed_at, closed_reason, archived_at, lease_expires_at, lease_paused_at, lease_remaining_ms, state ('reserved'|'launching'|'live'|'paused'|'draining'|'closed'|'crashed'), live, disable_evaluate, vault_enabled, stealth, fingerprint, humanize, stealth_recorded, identity, browser?:{version, sandboxed}, proxy_label, counts:{tool_calls, errors, pages, blocked, attention_open, vault_access}, has_live_viewers, harness, client:{name, version, agent_name?, model?, harness?, harness_label?, harness_source?, model_source?, workspace?, title?, protocol_version?, meta?}` — `browser` is the browser's real version (`null` when the driver has no `Browser` object to read it from; Chromium persistent contexts do expose one, measured) and whether it runs inside Chromium's sandbox (D-27, D-31): for a session whose browser is running it is read from the live handle; otherwise (closed, crashed, draining after teardown, or served from the stored row) it is the value recorded at launch (`sessions.sandboxed`, `sessions.browser_version`, schema v4). It is **absent** when nothing was recorded: sessions created before schema v4, sessions whose browser never launched (`reserved`, `launching`, `closed_reason = 'launch_failed'`) and drivers that report no launch facts. Absent means "not recorded", never "not sandboxed"; clients MUST NOT read it as `sandboxed: false`; `harness` is the harness that launched the session (02 §1.4), fixed at launch (`sessions.harness`), always present: a slug, or `unknown` for sessions whose launch identified nothing and for sessions created before schema v3. `client` is the launching MCP connection's self-reported identity (02 §1.4): `name`/`version`/`title` from `clientInfo`, `protocol_version`, `harness` (slug) with `harness_label` and `harness_source`, `model` with `model_source`, `workspace` (also as `agent_name`, kept for compatibility) and the capped `meta` bag; absent values are omitted. For a live session it is the identity resolved when it launched; for a stored session, the connection row's latest resolution. It is `null` when the session has no connection row, or when the client reported nothing at all (no name, version, workspace or model, and harness `unknown`). `counts` are live and identical in list rows, `GET /sessions/{id}` (`.session.counts` and top-level `counts`) and WS `session.updated`. For a live session they come from the in-memory aggregate (`app/sessions/session-counters.ts`), which subscribes to the same bus events the recorder persists: `tool.called` → `tool_calls` +1 and `errors` +1 when `error_code` is set (soft failures count); `page.visited` → `pages`; `blocklist.hit` → `blocked`; `vault.access` → `vault_access`; `attention.created`/`attention.resolved` → `attention_open` ±1. Every change publishes `session.updated` (coalesced 250 ms per session). `attention_open` counts pending **attention** requests only (vault confirms excluded, in the DB view too). A blocked `request_attention` counts in `tool_calls` once it returns. The takeover gate needs an open request with `mode = 'takeover'`; `attention_open > 0` alone is not sufficient. @@ -453,7 +453,7 @@ CREATE INDEX idx_sessions_harness ON sessions(harness, created_at); -- (first by the `session.updated` that marks it live, in the `register` phase); a later launch of the same session overwrites it (latest launch wins). -- NULL = not recorded (sessions created before v4, or whose browser never launched); never backfilled. ALTER TABLE sessions ADD COLUMN sandboxed INTEGER CHECK (sandboxed IN (0,1)); -ALTER TABLE sessions ADD COLUMN browser_version TEXT; -- NULL also for a persistent context (no `Browser` object) +ALTER TABLE sessions ADD COLUMN browser_version TEXT; -- NULL also when the driver had no `Browser` object to ask CREATE INDEX idx_sessions_open ON sessions(state) WHERE closed_at IS NULL; CREATE INDEX idx_sessions_owner ON sessions(owner, created_at); CREATE INDEX idx_sessions_created ON sessions(created_at, session_id); diff --git a/specs/09-testing.md b/specs/09-testing.md index 6eaf683..75f66a7 100644 --- a/specs/09-testing.md +++ b/specs/09-testing.md @@ -86,7 +86,7 @@ Domain and application (no I/O, fakes only): - Config resolver (`app/config/resolve.test.ts`): table-driven ladder cases (env < file < cli), shadow lines exact text, secrets redacted in shadow lines, derived provenance (`trace`, `fingerprint`, `maxSessions`, `dataDir`), every fail-fast message in `08-cli-arguments-and-config.md` §4 reproduced exactly, "did you mean" suggestions and the unsupported spellings of 08 §5.5, reserved keys rejected, empty env rejected, relative path resolution against cwd vs config-file dir, the data-dir-config rule, OTEL sub-source precedence, and config-file references (08 §3.1): the scanner table in `contracts/test/config.refs.test.ts` (whole, embedded, several per string, defaults with unset/empty/set, the escape, `{trace_id}` and other literal braces, malformed names, unknown and mis-cased schemes, `${env:…}`, no rescanning), the tree walk in `app/config/interpolate.test.ts` (array elements, object values, nested objects, keys never expanded, `at` paths), ladder rows with references (file beats env, shadowed by cli, secret key, default used, exact shadow-line text), every reference message of 08 §4 verbatim, several bad references reported together, and the warning for reference-shaped text in env, `OTEL_*`, flags and options. **Invariant** (`interface/http/serializers/config-refs.redaction.test.ts`): a sentinel routed through a reference into a secret key, and into a key whose reference name is credential-looking, appears in no shadow line, `config show` row, `configView`, `configKeysToWire` output or problem message. - Migration runner (`infra/persistence/migrations/runner.test.ts`, file-backed temp DB): fresh DB ends at `SCHEMA_VERSION`; each fixture DB `packages/core/test/persistence/fixtures/v.db` upgrades to head (`test/persistence/fixtures.test.ts`; one fixture per schema version, written by `generate-fixture.ts`) and its pragma-normalised fingerprint equals a fresh install; backup file is created before any DDL and retained per `backupsKeep`; a migration that throws mid-way leaves `user_version` unchanged and no partial schema (crash/rollback); a DB with `user_version > SCHEMA_VERSION` and `min_reader_version <= SCHEMA_VERSION` opens (compat window); with `min_reader_version > SCHEMA_VERSION` it refuses with `DB_NEWER_THAN_BINARY` naming the backup; `application_id` mismatch refuses; a second opener (`purge` inventory) never deadlocks; `db.exec` is used (a test asserts that a multi-statement migration executes all statements). - Schema snapshot (`test/persistence/schema-fingerprint.test.ts`): pragma-normalised schema of a fresh DB equals the committed golden `test/persistence/fixtures/schema-v.json`; adding a migration without updating the golden fails. -- Session browser record (D-31): `test/persistence/session-browser-migration.test.ts` upgrades `v3.db` to v4 and asserts every existing session keeps `sandboxed` and `browser_version` NULL and serves no `browser` (list and detail), that the columns round-trip through insert and update, and that the CHECK refuses a value other than 0/1; `app/sessions/metadata.test.ts` pins the write path (a reserved session's record and patch carry nothing, a launched one carries the handle's facts, a detached one keeps them, a later launch overwrites them, a persistent context records a NULL version with its verdict) and the summary fallback (live handle first, then the recorded launch); `interface/http/serializers/serializers.test.ts` pins the stored row and the live overlay (a recorded row serves `browser`, a NULL row omits it, the live value wins); the dashboard tests cover the four Sandbox states (sandboxed, not sandboxed, not launched, not recorded). +- Session browser record (D-31): `test/persistence/session-browser-migration.test.ts` upgrades `v3.db` to v4 and asserts every existing session keeps `sandboxed` and `browser_version` NULL and serves no `browser` (list and detail), that the columns round-trip through insert and update, and that the CHECK refuses a value other than 0/1; `app/sessions/metadata.test.ts` pins the write path (a reserved session's record and patch carry nothing, a launched one carries the handle's facts, a detached one keeps them, a later launch overwrites them, a launch without a readable version records a NULL version with its verdict) and the summary fallback (live handle first, then the recorded launch); `interface/http/serializers/serializers.test.ts` pins the stored row and the live overlay (a recorded row serves `browser`, a NULL row omits it, the live value wins); the dashboard tests cover the four Sandbox states (sandboxed, not sandboxed, not launched, not recorded). - Repository conformance (`infra/persistence/repositories.conformance.test.ts`): one suite parameterized over adapters; runs against the SQLite adapter on `:memory:` today and is the acceptance suite for any future adapter. Covers every repository method, keyset pagination stability under concurrent inserts, `ON DELETE CASCADE` for every `session_id` FK, `INSERT` idempotency on primary keys, transaction rollback via `UnitOfWork`. - Retention (`app/observability/retention.test.ts`): every table has a retention class asserted over the table list; age prune; byte cap converges without `VACUUM` in a loop; artifact deletion via the outbox; `pending_attention`/operator-request rows are pruned once terminal; failures are counted and surfaced, never thrown out of the timer. - Error registry (`kernel/errors/*.test.ts`): every code projects to MCP, problem+json, and WS frames; `docs/errors.md` generation is deterministic; `INTERNAL_ERROR` never carries host paths in the public message. From 3364aac6b266966be29d10d71a9e1248f7e92e9e Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Sat, 26 Sep 2026 22:28:42 -0400 Subject: [PATCH 5/5] docs(sessions): per-session sandbox record in the dashboard, security and upgrading guides; changeset --- .changeset/session-sandbox-record.md | 10 ++++++++++ docs/guide/dashboard.md | 4 ++-- docs/guide/security.md | 2 +- docs/guide/upgrading.md | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/session-sandbox-record.md diff --git a/.changeset/session-sandbox-record.md b/.changeset/session-sandbox-record.md new file mode 100644 index 0000000..0daf6a6 --- /dev/null +++ b/.changeset/session-sandbox-record.md @@ -0,0 +1,10 @@ +--- +"browserhive": minor +--- + +Closed sessions now keep showing whether they ran inside Chromium's sandbox, and with which browser version. + +- **Recorded at launch.** When a session's browser starts, BrowserHive stores whether it runs sandboxed and the browser's real version with the session. Under the default `--sandbox auto` the answer depends on the browser and the machine (on Ubuntu, Google Chrome sandboxes and the bundled Chromium falls back), so it is recorded rather than worked out later. +- **In the dashboard.** A session's Details tab shows the browser version and a **sandboxed** / **not sandboxed** state for finished sessions too, not only while they run. Sessions from before this release show **not recorded**, with a note that this does not mean the sandbox was off; a session whose browser never started shows **not launched**. +- **In the API.** `browser: { version, sandboxed }` on `GET /api/v1/sessions` and `GET /api/v1/sessions/{id}` is now filled for closed sessions from what was recorded at launch. It is still left out when nothing was recorded, so read a missing `browser` as "unknown", never as "not sandboxed". +- The database upgrades on start (schema v4, two new columns, a backup is written first); older releases can still open it. Nothing is guessed for existing sessions. diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 4f7d731..342bb97 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -37,7 +37,7 @@ Every session, live and finished. Filter by state (live, closed, archived), owne ## Session detail -The header shows the session id, state and actions: terminate, open the trace viewer, download `trace.zip`, archive, delete. When the agent is waiting on you, a banner shows the attention request or pending vault confirmations. A side rail lists owner, channel, headless or headed, persistence, current URL, lease and identity. The **Client** panel shows the harness and how it was recognised, the declared model ("not reported" when none), the workspace, the client's name, version and protocol, and any extra labels it sent. +The header shows the session id, state and actions: terminate, open the trace viewer, download `trace.zip`, archive, delete. When the agent is waiting on you, a banner shows the attention request or pending vault confirmations. A side rail lists owner, channel, headless or headed, persistence, current URL, lease and identity. The **Client** panel shows the harness and how it was recognised, the declared model ("not reported" when none), the workspace, the client's name, version and protocol, and any extra labels it sent. The **Session** panel shows the browser with its real version and whether it ran inside Chromium's sandbox. Both are recorded when the browser launches, so a closed session still shows them. A session from before this was recorded (BrowserHive 0.1.x) shows **not recorded**, which does not mean the sandbox was off; a session whose browser never started shows **not launched**. Tabs: @@ -81,7 +81,7 @@ A live tail of the server log with level, module, session and trace filters, pau ## System -Version, transport, uptime, bind address, sessions live versus the cap, open attention requests, live views, dashboard connections, default persistence, whether `evaluate` is allowed, vault backend, blocklist, retention, database size and schema version, telemetry endpoint and data directory. A stealth section shows the stealth level, driver, fingerprint and humanize defaults and CAPTCHA mode. **Browsers and sandbox** lists the browsers found on the machine (the bundled Chromium, an installed Google Chrome or Microsoft Edge) with version and path, marks the default one, and shows for each whether it runs inside Chromium's sandbox, with Chrome's own reason when it cannot. **MCP connections** lists the agents connected over MCP, live ones first, then recent ones; select one to see how its harness was recognised, its model and workspace, client and protocol, User-Agent, IP, any conflicting signals and extra labels. A session's Details tab shows its browser version and whether that session runs sandboxed. Banners warn when `evaluate` is enabled together with the vault, when disk space is low, or when a subsystem is degraded. The configuration table lists every effective setting with its source (`cli`, `file`, `env`, `default`, `derived`) and the values it shadowed; secrets are shown as redacted. A value the config file reads from an environment variable has a `$NAME` chip under its source (outlined when the variable was not set and the default is used); select it to see the value as written in the file, or, for a secret, just the variable. **Only values from references** narrows the table to those keys, and the filter also matches variable names. See [References](configuration.md#references). +Version, transport, uptime, bind address, sessions live versus the cap, open attention requests, live views, dashboard connections, default persistence, whether `evaluate` is allowed, vault backend, blocklist, retention, database size and schema version, telemetry endpoint and data directory. A stealth section shows the stealth level, driver, fingerprint and humanize defaults and CAPTCHA mode. **Browsers and sandbox** lists the browsers found on the machine (the bundled Chromium, an installed Google Chrome or Microsoft Edge) with version and path, marks the default one, and shows for each whether it runs inside Chromium's sandbox, with Chrome's own reason when it cannot. **MCP connections** lists the agents connected over MCP, live ones first, then recent ones; select one to see how its harness was recognised, its model and workspace, client and protocol, User-Agent, IP, any conflicting signals and extra labels. A session's Details tab shows its browser version and whether that session ran sandboxed, for live and closed sessions alike. Banners warn when `evaluate` is enabled together with the vault, when disk space is low, or when a subsystem is degraded. The configuration table lists every effective setting with its source (`cli`, `file`, `env`, `default`, `derived`) and the values it shadowed; secrets are shown as redacted. A value the config file reads from an environment variable has a `$NAME` chip under its source (outlined when the variable was not set and the default is used); select it to see the value as written in the file, or, for a secret, just the variable. **Only values from references** narrows the table to those keys, and the filter also matches variable names. See [References](configuration.md#references). ## Notifications diff --git a/docs/guide/security.md b/docs/guide/security.md index 72090cf..67f104e 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -133,7 +133,7 @@ Ubuntu 23.10 and later only let programs with an AppArmor profile create the use The profile has the same shape as the one Ubuntu ships for Google Chrome. BrowserHive only prints it; it never installs anything. -`browserhive doctor` shows, per installed browser, whether it can run sandboxed here and, when your configured browser cannot, the options for your machine. The dashboard's System page lists the browsers and each one's sandbox state, and a session's Details tab shows whether that session runs sandboxed. +`browserhive doctor` shows, per installed browser, whether it can run sandboxed here and, when your configured browser cannot, the options for your machine. The dashboard's System page lists the browsers and each one's sandbox state, and a session's Details tab shows whether that session ran sandboxed. That is recorded when its browser launches and kept with the session, so you can still check it after the session closed (`browser.sandboxed` in `GET /api/v1/sessions/{id}`). Sessions from before BrowserHive 0.2 show "not recorded". An agent can ask for the sandbox for one session (`launch_options: { chromiumSandbox: true }`); if the browser cannot provide it, the launch fails with `SANDBOX_UNAVAILABLE` instead of starting unsandboxed. An agent can never turn the sandbox off: `chromiumSandbox: false` is refused. diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index 3189f71..f01f205 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -27,6 +27,8 @@ browserhive db migrate **The browser sandbox is on by default since the release that added `--sandbox`.** Sessions now run inside Chromium's sandbox wherever the machine allows it (`sandbox=auto`). Where it cannot (Ubuntu 23.10+ with the bundled browser, root, Docker), sessions run as before and `browserhive doctor` explains why and how to fix it (still exit code 0). `--sandbox off` restores the previous behaviour exactly. See [Security: the browser sandbox](security.md#the-browser-sandbox). +**Each session records whether it ran sandboxed (schema v4).** From 0.2 on, the database stores each session's sandbox state and browser version when its browser launches, so closed sessions keep showing them. The migration only adds columns: an older release still opens the database. Sessions from before the upgrade show "not recorded"; nothing is guessed for them. + Prereleases are published under the `next` tag: `bun add -g browserhive@next`. ## Downgrade