Skip to content

feat: Environment browser on the web, with an agent-browser session per thread - #41

Merged
immakermatty merged 6 commits into
mainfrom
agent/DEV-6646-environment-browser
Oct 5, 2026
Merged

immakermatty merged 6 commits into
mainfrom
agent/DEV-6646-environment-browser

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 5, 2026 •

Copy link
Copy Markdown

Status: in progress — awaiting DEV-6646 integration review

Why

Root decision 0191 (plan DEV-6646): on a Lazurio Remote Environment the agents of every thread work in the Environment browser, one shared headed Chromium on the Environment. Each thread drives a window of its own through the agent-browser CLI, and a person watches and takes over through a view (the agent-browser dashboard) behind the Environment's gateway.

T3 Code's web client has no browser: the right panel's Browser is disabled ("Only available in the desktop app") wherever Electron's desktopBridge.preview is missing. Nothing told an agent which agent-browser session belongs to its thread either.

What

Two overlays, each with a small seam in upstream code.

A. Server: an agent-browser session per thread. Lives in apps/server/src/lazurio/agentBrowserSession.ts, plus one call in ProviderService.ts.

  • Every provider process T3 starts for a thread gets AGENT_BROWSER_SESSION=<name>.
    • Ordinary threads. An id that already fits agent-browser's grammar ([A-Za-z0-9_-]) within its dashboard's 64 characters gets exactly t3-<thread id>. Every ordinary thread id is a UUID, so this is the normal case.
    • Any other id. Characters outside the grammar become - and the name is cut. It then ends in 32 hex digits (128 bits) of the SHA-256 of the exact id, so two threads never share a session (a 32-bit FNV-1a suffix in an earlier head could be made to collide; see the review). Long imported ids (import:<instance>:<session>, where an instance id may take 64 characters) are the case this covers.
  • ProviderService.prepareMcpSession records the name with the thread's provider session (withAgentBrowserSession). Every adapter already spreads that session's environment into the processes it starts for the thread (withAgentDeviceEnvironment, the path the agent-device CLI takes). So Codex's per-thread app-server, Claude Code, Cursor, Grok, OpenCode and Antigravity all get it without adapter changes.
  • The thread's own name wins over one inherited from T3's environment. It only names a session and grants nothing.

B. Web: the right panel's Browser shows the Environment browser. Lives in apps/web/src/lazurio/, with seams in rightPanelStore.ts, RightPanelTabs.tsx and ChatView.tsx.

  • When it asks. Only without the desktop preview, and only for threads of the environment that serves the page. When the right panel opens, it asks this origin for GET /.lazurio/browser.json?session=<name> with credentials: "same-origin", redirect: "error", cache: "no-store" and a 10 s timeout.
  • What enables Browser. Only a 200 {"available": true, "view": "<https URL>", "session": "<name>" | null}.
  • What keeps it disabled. Everything else keeps today's disabled state and text: available: false, any other status (a 404 from an older Platform, 400, 202, and so on), a network error, an answer that is not JSON (including T3's own index.html fallback), a view that is not https: or sits on this page's origin, or a different session.
  • The surface. Opening Browser adds a new surface kind, environment-browser. It cannot be preview, because reconcileBrowserSurfaces drops preview surfaces that have no live server tab.
  • The view URL is never stored. It carries a short-lived token in its fragment, so the surface asks for it again on every open and on Reload. The URL lives only in component state; the persisted surface is just {id, kind}.
  • The frame. An <iframe allow="clipboard-read; clipboard-write; fullscreen" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"> fills the panel.
    • A slim header holds "Environment browser", Reload, and "Open in new tab" (target=_blank rel=noopener, same URL). The new tab is there because the gateway's sign-in page cannot render inside a frame.
    • With allow-same-origin the frame keeps its own origin, so a view on T3's own origin is refused.
  • No profile chooser on the web. The desktop browser-profile chooser only shows where the desktop preview exists; the web has no profiles to pick.
  • Two copies of the name function. The session name lives in apps/server/src/lazurio/agentBrowserSession.ts and apps/web/src/lazurio/agentBrowserSession.ts. Sharing it through packages/shared would need an entry in that package's export map, and upstream has changed that package.json in 31 commits since August. The release contract test asserts the two copies are the same function.

Fork policy.

  • Every touched upstream client file is in allowed_upstream_changes with a reason. ChatView.tsx was already listed.
  • CI now also runs vp test run src/lazurio in apps/server.
  • The runbook gets two "retain" rows and a section called "Prohlížeč Environmentu".

Review. Greptile raised four findings. Two were real and are fixed in follow-up commits:

  • Cut or rewritten ids could share a session: fixed in 1bb1015.
  • Only a 200 answer should enable Browser: fixed in ab3a24b.

The other two are answered in their threads and left as designed:

  • Browser keeps the cached answer while the check runs on reopen.
  • Reopening during the panel's exit animation keeps the live frame.

What does not change

  • Desktop app with desktopBridge.preview: no request is made, Browser is the desktop preview, and profiles work as before.
  • Unchanged areas: the mobile app, wire contracts and packages/.
  • No Environment browser: where the Environment offers none, or on any non-Lazurio deployment, Browser stays disabled with the upstream text.
  • Desktop-only paths: the preview.toggle keybinding and "open in preview" links still open URLs in a desktop preview tab only.
  • Other environments: threads on non-primary environments keep upstream behaviour.

Verified

  • Lazurio Fork CI on ab3a24b, all green (run 37375471330):
    • Server and web compatibility: allowlist guard, vp fmt --check, typecheck of server and web, the hosted-server tests, the new Test the Lazurio server overlay, Test the Lazurio web overlay, the release contract test and the release image build.
    • CLI archives / Build darwin-arm64, CLI archives / Build linux-x64 and CLI archives / Launcher install linux-x64.
    • The run on the first head (fb2c560) lost its Linux jobs to the GitHub Actions incident of 2026-10-05. Its darwin archive job passed.
  • Run locally on Node 24.16.0 with pnpm 11.10.0:
    • pnpm exec vp fmt --check: clean.
    • pnpm exec vp run --filter t3 typecheck and pnpm exec vp run --filter @t3tools/web typecheck: pass.
    • pnpm exec vp lint --report-unused-disable-directives on every changed file: exit 0 and no new warnings. ChatView.tsx (69) and RightPanelTabs.tsx (1) have the same warnings as the upstream baseline.
    • cd apps/server && pnpm exec vp test run src/lazurio: 6/6. This covers the name rule (exact for clean ids; distinct, readable and in the grammar for rewritten, long and imported ids), the merge into the provider-session environment, ProviderService recording the name before the adapter starts, and the environment the Codex app-server and Claude Code start with.
      • Mutation check: without the seam in ProviderService.ts, the ProviderService test fails.
    • cd apps/web && pnpm exec vp test run src/lazurio: 19/19 (this includes the prompt-draft tests). This covers the name, every browser.json case (available, unscoped, not offered, 404, 400, 202, network or redirect, HTML, not JSON, http, relative, javascript:, this page's origin, missing or mistyped fields, another session, other response origin, array, timeout) and the surface store (singleton, survives preview reconciliation, toggle, close, persisted as {id, kind}).
    • node --test scripts/lazurio-release-contract.test.mjs: 17/17.
    • Upstream suites of the touched modules:
      • web src/rightPanelStore.test.ts src/components/RightPanelTabs.test.tsx src/components/ChatView.logic.test.ts: 228/228.
      • server src/provider/Layers/ProviderService.test.ts src/mcp/McpProviderSession.test.ts: 94/94.
      • src/server.test.ts: 212/212.
      • adapter suites (Claude, Codex, Cursor, Grok, OpenCode, Antigravity): 415/416. The one failure, AntigravityAdapter > serves client file reads and writes only inside the session roots, fails the same way on the unchanged base. It is a macOS /var vs /private/var temp-path issue that does not affect Linux CI.
    • pnpm exec vp run --filter @t3tools/web build and pnpm exec vp run --filter t3 build: both pass. The bundles carry AGENT_BROWSER_SESSION and /.lazurio/browser.json.
  • Not run: a browser pass. No live Environment browser was available, so it belongs to the integration review.

Risks

  • Framing. The view's origin must allow being framed by the T3 origin (frame-ancestors / X-Frame-Options on the gateway's browser. origin). The Platform and gateway own this; it is to be confirmed in the integration review.
  • The sandbox. allow-scripts together with allow-same-origin only confines the frame because the view is on another origin, so a same-origin view is refused. The lint rule is suppressed on that line with the reason.
  • Delay before Browser enables. Browser stays disabled until the Environment answers, which is up to 10 s if the view has to start cold. On reopen, the last answer for the thread shows while it is checked again.
  • Dependence on the MCP credential. The variable rides the thread's provider session, which is recorded whenever the MCP credential is issued. That happens for every session a server starts, because the MCP session registry is part of every server layer. A server without the registry (tests only) starts providers without the variable.
  • Computing the name elsewhere. Anything outside T3 that derives a session name from a T3 thread id must use the same rule: plain t3-<id> for clean UUIDs, otherwise the cut name plus 32 hex digits of the id's SHA-256. Today nothing outside T3 derives it; the Platform reads AGENT_BROWSER_SESSION as given.

Related

🤖 Generated with Claude Code

immakermatty and others added 3 commits October 5, 2026 22:36
…session

On a Lazurio Remote Environment the agents of every thread work in the
Environment browser, one shared Chromium, through the agent-browser CLI,
each thread in a window of its own (root decision 0191, plan DEV-6646).
The CLI takes its session from AGENT_BROWSER_SESSION, so every provider
process T3 starts for a thread now carries the thread's name: `t3-` and
the thread id with every character outside [A-Za-z0-9_-] replaced by
`-`, at most 64 characters (agent-browser's grammar and its dashboard's
limit).

ProviderService records the name with the thread's provider session,
whose environment every adapter already spreads into the processes it
starts for the thread (the path the agent-device CLI takes). So the
Codex app-server, Claude Code and the other adapters get it from one
seam: a single call in ProviderService.ts; the rest lives in
apps/server/src/lazurio/. The name wins over one T3 itself inherited.
It only names the session: nothing is granted, and it is set whether
or not T3's own agent browser access is on.

Tests cover the name for UUIDs, dots, colons, slashes, non-ASCII and
long ids, ProviderService recording it before the adapter starts, and
the environment the Codex app-server and Claude Code start with. CI
runs them (`vp test run src/lazurio` in apps/server) and the release
contract test keeps the seam.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The web client has no browser of its own: the right panel's Browser is
disabled with "Only available in the desktop app" wherever Electron's
desktopBridge.preview is missing. On a Lazurio Remote Environment one
shared Chromium runs on the Environment, each thread's agents drive a
window of their own in it, and a person watches and takes over through
a view behind the Environment's gateway (root decision 0191, plan
DEV-6646).

Without the desktop preview, and only for threads of the environment
that serves the page, the right panel now asks this origin for
GET /.lazurio/browser.json?session=<the thread's agent-browser session>
(same-origin credentials, redirects refused, no cache, 10 s timeout).
The Environment's Launchpad answers there behind the same sign-in. A
200 {"available": true, "view": <https URL>, "session": <name> | null}
enables Browser; anything else (available false, a 404 from an older
Platform, a network error, T3's own index.html, a view that is not
https: or is on this page's origin, another session) keeps today's
disabled state and text.

Opening it adds a surface kind of its own, environment-browser:
preview surfaces are dropped by reconcileBrowserSurfaces when the
server has no tab for them. The surface asks for the view each time it
opens and on Reload, because the view URL carries a short-lived access
token in its fragment; the URL lives only in component state, and the
persisted surface is {id, kind}. The view is framed with
allow="clipboard-read; clipboard-write; fullscreen" and
sandbox="allow-scripts allow-same-origin allow-forms allow-popups
allow-downloads allow-modals"; the header has Reload and "Open in new
tab", since the gateway's sign-in page cannot render in a frame once the
sign-in has expired. Desktop profiles are offered only where the desktop
preview exists.

The desktop app is unchanged: with desktopBridge.preview nothing is
asked and Browser is the desktop preview. Upstream files change only at
seams (the surface kind in rightPanelStore.ts, its tab in
RightPanelTabs.tsx, enabling and rendering it in ChatView.tsx); the rest
is in apps/web/src/lazurio/, which holds a copy of the session name
function. The allowlist lists every file; the release contract test
keeps the seams, the same-origin fetch, the frame attributes, that the
URL is never stored, and that the server's and the web's names are one
function.

Refs #40, #28.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The overlay table gains the two commits of root decision 0191 (plan
DEV-6646), both "retain": the thread's agent-browser session for every
provider process, and the right panel's Browser showing the Environment
browser on the web, the documented minimal UI intervention, since the
web client has no browser. The intro no longer calls the shell slot the
only UI intervention.

A section "Prohlížeč Environmentu" says what the server sets and how,
what the web asks /.lazurio/browser.json and what counts as available,
why the surface is a kind of its own, that the view URL is asked for on
every open and never stored, the frame's attributes and the new-tab way
out, what does not change (desktop, mobile, the preview.toggle shortcut),
which upstream files carry seams and what the contract test and CI keep.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread apps/server/src/lazurio/agentBrowserSession.ts
Comment thread apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx
Comment thread apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx
Comment thread apps/web/src/lazurio/environmentBrowser.ts Outdated
immakermatty and others added 2 commits October 5, 2026 23:21
…ts own

Replacing characters outside [A-Za-z0-9_-] and cutting at 64 characters
can make two thread ids read alike. An imported thread's id is
import:<provider instance>:<provider session>, and an instance id may take
64 characters, so cutting alone would give every thread imported from such
an instance the same session, and their agents would drive one window
(review finding on #41).

The name stays t3-<thread id> whenever the id already fits agent-browser's
grammar and length, which is every ordinary thread (a UUID). Any other id
keeps a readable start of its sanitized form and ends in an FNV-1a hash of
the exact id, still at most 64 characters. The server's and the web's
copies change together; the contract test keeps them one function, the
tests cover long imported ids and ids that differ only in rewritten
characters, and the runbook states the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The /.lazurio/browser.json contract enables Browser for a 200 answer with
an available view; the check accepted any 2xx status (review finding on
#41). It now takes exactly 200, the test covers a 202 with an otherwise
valid document, and the contract test keeps the check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P1 — Imported threads can still share one browser session (apps/server/src/lazurio/agentBrowserSession.ts:28-33, mirrored in web). The 8-hex-digit FNV-1a suffix is not unique. I ran the exact function on this head: for prefix = "import:codex-" + "w".repeat(58) + ":", agentBrowserSessionName(prefix + "f64cccd6-59c8-42a7-aa0e-319969aeccc9") equals agentBrowserSessionName(prefix + "3c4a8834-35dc-418d-a6dd-d8d1934ab83f"); both yield t3-import-codex-wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww-dc7d7bc4. These are distinct, valid imported thread IDs of the shape generated by AgentSessionImporter.ts:168-170. Their providers therefore receive the same AGENT_BROWSER_SESSION and can drive the same window, violating the per-thread isolation this change promises. Please address the collision (e.g. a sufficiently long collision-resistant digest plus this regression pair, or collision-checked persistent allocation if strict uniqueness is required) and synchronize the server/web implementations. The 17/17 release-contract tests pass locally, but do not cover this case. No merge/release action taken.

Pablo's review of #41 found two distinct imported thread ids of the same
long instance whose 32-bit FNV-1a suffixes collided, so their providers
would have shared one agent-browser session and one window. The suffix is
now the first 32 hex digits of the exact id's SHA-256 (node:crypto on the
server, @noble/hashes in the web; one function text, compared by the
release contract test). Ordinary ids are still exactly t3-<id>. The
colliding pair is a regression case on both sides and in the contract
test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Author

@agentrozjedemeai Fixed in 1a43117: the suffix of a cut or rewritten id is now the first 32 hex digits (128 bits) of the exact id's SHA-256 (node:crypto on the server, @noble/hashes in the web, one function text compared by the release contract test). Your colliding pair is a regression case on both sides and in the contract test. Locally on Node 24.16: format check, server and web typecheck, server overlay 6/6, web overlay 19/19, contract 17/17.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@immakermatty Approved at exact head 1a43117. The prior P1 collision pair now maps to distinct 64-character, grammar-valid session names with the 128-bit SHA-256 suffix on both server and web; ordinary UUIDs retain t3-. I inspected the one-commit repair and the server/web parity and release contract, and ran the isolated server overlay tests (6/6), web overlay tests (19/19), release contract (17/17), format check, and server/web typechecks (all passed; local Node 26 versus required Node 24). Fork CI has four successful jobs on this head; review threads are resolved. No browser integration smoke was available. This approval is review-only, not merge or release authorization.

@immakermatty
immakermatty merged commit f9d8ab6 into main Oct 5, 2026
7 checks passed
immakermatty added a commit that referenced this pull request Oct 5, 2026
…ts own

Replacing characters outside [A-Za-z0-9_-] and cutting at 64 characters
can make two thread ids read alike. An imported thread's id is
import:<provider instance>:<provider session>, and an instance id may take
64 characters, so cutting alone would give every thread imported from such
an instance the same session, and their agents would drive one window
(review finding on #41).

The name stays t3-<thread id> whenever the id already fits agent-browser's
grammar and length, which is every ordinary thread (a UUID). Any other id
keeps a readable start of its sanitized form and ends in an FNV-1a hash of
the exact id, still at most 64 characters. The server's and the web's
copies change together; the contract test keeps them one function, the
tests cover long imported ids and ids that differ only in rewritten
characters, and the runbook states the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
immakermatty added a commit that referenced this pull request Oct 5, 2026
The /.lazurio/browser.json contract enables Browser for a 200 answer with
an available view; the check accepted any 2xx status (review finding on
#41). It now takes exactly 200, the test covers a 202 with an otherwise
valid document, and the contract test keeps the check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@immakermatty
immakermatty deleted the agent/DEV-6646-environment-browser branch October 7, 2026 05:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants