Context
ego-lite demonstrates a useful Agent-browser primitive: each Agent gets a dedicated browser Space instead of sharing one undifferentiated tab pool.
OpenCLI-Razormind already has persistent BrowserInstance, BrowserBinding, runtime bundles, browser capability manifests and an authenticated capability invocation route. It does not yet have a durable Workspace-scoped ownership boundary saying which Agent may use which browser runtime. This causes ambiguity around parallel Agent work and makes accidental operator-tab reuse difficult to prevent.
This is a focused follow-up to #15 and the sibling persistent Global Agent session issue. It is deliberately smaller than ego-lite: Docker/Chromium runtime slots remain the physical browser, and this slice adds the ownership/lifecycle/lease boundary around one dedicated slot. It does not port ego-lite's macOS desktop application or arbitrary JavaScript execution model.
Current State
| Capability |
Current state |
Evidence |
| Persistent browser slot |
BrowserInstance stores endpoint, profile, runtime bundle and network policy |
backend/models/browser.py:33-57 |
| Runtime capability execution |
browser_capability_service.invoke_capability validates bundle, host, args and gate before dispatch |
backend/services/browser_capability_service.py:83-171 |
| HTTP capability route |
Authenticated route invokes a selected runtime capability |
backend/api/v1/browsers.py:239-263 |
| Agent-owned browser Space |
Missing |
No BrowserSpace model or Workspace-scoped Space API exists |
| Durable replay pattern |
Workbench has turn/event persistence and ordered replay |
backend/models/workbench.py:39-133, backend/api/v1/workbench.py:172-236 |
Goal
Add a durable BrowserSpace that reserves exactly one existing BrowserInstance for one Workspace and owner identity. Add a durable task/lease record for one structured capability invocation at a time.
V1 isolation guarantee: one BrowserInstance can have at most one active Space. Parallel Spaces therefore require distinct existing browser instances. This is a real, testable guarantee and prevents task/tab collisions without inventing a new browser context protocol.
The Space must use the existing Agent/runtime route. It must never attach to an operator's personal Chrome tabs or expose a new public CDP endpoint.
Non-negotiable boundaries
- No arbitrary user-supplied JavaScript. A task invokes one named capability already present in the selected runtime bundle.
- Reuse
get_request_identity, existing Workspace authorization helpers and browser_capability_service.invoke_capability; do not duplicate auth or the CDP client.
- No credentials, cookies, bearer tokens, CDP endpoints, profile paths or raw page HTML in new API responses, task/event rows or logs.
- Workspace, owner, BrowserInstance and optional BrowserBinding are validated before any task row or browser side effect is created.
- A Space may be
idle, running, closed or error; a closed Space rejects new tasks.
- A task is
queued, running, completed, failed or cancelled. Cancellation is idempotent; it may be acknowledged as cancel_requested internally but is not reported as cancelled until cleanup finishes.
- If the requested BrowserInstance is already reserved by another active Space, return
409 browser_instance_in_use; never silently share it.
- If the runtime is not READY or the requested capability is not granted/loaded, return the existing typed runtime error and persist a failed task without leaking the underlying secret-bearing payload.
Data Model
Create backend/models/browser_space.py using TimestampMixin and the repository's UUID/SQLAlchemy conventions.
BrowserSpace
- id: UUID primary key
- workspace_id: FK workspaces.id, required, indexed
- browser_instance_id: FK browser_instances.id, required, indexed
- binding_id: FK browser_bindings.id, nullable, indexed
- owner_type: operator | runtime_agent
- owner_id: opaque authorized identity
- status: idle | running | closed | error
- granted_capabilities: JSON array of capability names
- revision: integer default 0
- last_error_code: string <= 64 nullable
- created_at / updated_at
BrowserSpaceTask
- id: UUID primary key
- space_id: FK browser_spaces.id cascade delete, indexed
- workspace_id: FK workspaces.id, required, indexed
- request_id: string <= 64
- operation_id: string <= 64, unique
- capability: string <= 255
- args: JSON, validated before execution
- status: queued | running | completed | failed | cancelled
- result: JSON nullable, bounded/redacted
- error_code: string <= 64 nullable
- error_message: text nullable, redacted
- created_at / started_at / finished_at / updated_at
BrowserSpaceEvent
- id: UUID primary key
- space_id: FK browser_spaces.id cascade delete, indexed
- task_id: FK browser_space_tasks.id cascade delete, indexed
- sequence: integer
- kind: queued | started | completed | failed | cancel_requested | cancelled
- payload: JSON bounded/redacted
- created_at
Constraints:
UNIQUE (browser_instance_id) for active/non-closed Spaces, enforced transactionally
UNIQUE (space_id, request_id)
UNIQUE (operation_id)
UNIQUE (space_id, sequence)
CHECK owner_type IN ('operator', 'runtime_agent')
CHECK status values match the enums above
The active-space uniqueness may use a repository-compatible partial index or a transactionally safe service lock if the migration target cannot express it. The invariant is more important than the exact DDL form.
Do not alter the meaning of existing BrowserInstance, BrowserBinding or BrowserCapabilityInvocation rows. Existing invocation rows may retain their current audit behavior; new Space rows and events must use the redacted bounded representation above.
API Contract
Create backend/api/v1/browser_spaces.py and register it in backend/api/v1/__init__.py. Keep existing /browsers/* routes unchanged.
GET /api/v1/workspaces/{workspace_id}/browser-spaces?limit=20
POST /api/v1/workspaces/{workspace_id}/browser-spaces
GET /api/v1/workspaces/{workspace_id}/browser-spaces/{space_id}
POST /api/v1/workspaces/{workspace_id}/browser-spaces/{space_id}/tasks
POST /api/v1/workspaces/{workspace_id}/browser-spaces/{space_id}/cancel
POST /api/v1/workspaces/{workspace_id}/browser-spaces/{space_id}/close
GET /api/v1/workspaces/{workspace_id}/browser-spaces/{space_id}/events?after_sequence=0&limit=100
Create request:
{
"browser_instance_id": "existing-instance-id",
"binding_id": "optional-existing-binding-id",
"owner_type": "operator | runtime_agent",
"owner_id": "authorized-opaque-identity",
"granted_capabilities": ["snapshot", "navigate"]
}
Task request:
{
"request_id": "client-generated-idempotency-key",
"capability": "snapshot",
"args": {},
"timeout_seconds": 60
}
Response shape:
{
"data": {
"space_id": "opaque-id",
"task_id": "opaque-id",
"operation_id": "opaque-id",
"status": "queued | running | completed | failed | cancelled",
"result": {},
"error": null
}
}
Fixed HTTP behavior:
401 missing identity.
403 Workspace/owner authorization failure.
404 unknown or foreign Space/BrowserInstance/Binding.
409 active BrowserInstance reservation, closed Space, same-Space active task, or duplicate operation conflict.
422 invalid request, capability, args or timeout.
200 list/detail/event/idempotent response.
202 accepted task that continues asynchronously.
Never return raw BrowserInstance.endpoint, agent_url, cookies, authorization headers, profile paths, credentials or unbounded HTML. Result/event payloads are capped at 64 KiB; oversize output is replaced with { "truncated": true, "reason": "result_too_large" }.
Service and Execution Rules
- Add
backend/services/browser_space_service.py or extend the existing browser service. Routers only parse requests, resolve dependencies and map typed errors.
create_space authorizes the Workspace and owner, validates the BrowserInstance/Binding, and atomically reserves the BrowserInstance. A second concurrent create receives 409 browser_instance_in_use and creates no Space.
submit_task checks Space ownership, capability grant and Space status, inserts the idempotent task and queued event, then commits before execution.
- Execute through
browser_capability_service.invoke_capability; pass only the validated capability args and existing gate authorization. Do not call CDP directly.
- The executor updates task/Space status and events in short transactions. No database transaction may remain open during the runtime call.
- One Space has at most one
queued/running task. A second submission returns 409 space_task_in_progress; distinct Spaces backed by distinct BrowserInstances may run concurrently.
- Repeating a
request_id returns the original task without a second runtime call. Handle the unique-constraint race by reloading the existing task after rollback.
- Cancellation records
cancel_requested, asks the executor to stop, releases the task lease/resources, then records cancelled only after acknowledgement. For an already terminal task, return its existing terminal state.
- Runtime errors persist a stable code and redacted message, emit
failed, set the Space to error only when the runtime allocation is unusable, and never return a false completed response.
- Closing an idle Space releases its reservation and emits the terminal event. Closing a running Space first follows the cancellation path.
- Events are append-only and ordered by
(space_id, sequence). Event reads return ascending order after after_sequence and at most 100 rows.
BrowserSpaceTask.args is never returned verbatim. The API may expose only argument keys or a redacted structured result.
Frontend Scope
Add a focused Spaces panel to the existing browser operator surface in frontend/app/(app)/browsers/page.tsx and the existing API endpoint/hooks/types modules:
- list Spaces for the current Workspace;
- create a Space from an allowed existing BrowserInstance;
- submit a named capability with bounded args;
- show task status, ordered events and redacted result;
- cancel or close with explicit confirmation;
- show
browser_instance_in_use, space_task_in_progress and runtime error codes without offering a shared-tab fallback.
Do not add a new top-level AI page or a browser desktop application.
Acceptance Criteria
- Authorized creation with one existing BrowserInstance returns a Space scoped to the requested Workspace and owner.
- A different Workspace or unauthorized owner cannot list, read, create, submit, cancel or close that Space.
- Two concurrent creates for the same BrowserInstance produce at most one active Space; the loser gets
409 browser_instance_in_use with no leaked row.
- A granted capability executes through the existing capability service and returns a bounded redacted result.
- An ungranted/unknown capability or invalid args is rejected before runtime dispatch.
- Two tasks on one Space never execute concurrently; the second gets
409 space_task_in_progress.
- Tasks on two Spaces backed by two BrowserInstances can execute concurrently in the deterministic fake-executor integration test.
- Repeating a
request_id produces one task and one runtime call.
- Cancellation is idempotent and does not report
cancelled before executor acknowledgement and cleanup.
- Runtime failure persists a failed task, ordered failure event and stable error code.
- Closing a Space prevents new tasks and releases its BrowserInstance reservation.
- No new Space/task/event API response or row contains endpoint URLs, agent URLs, cookies, credentials, authorization headers, profile paths or unbounded HTML.
- Event replay returns ascending bounded events and supports
after_sequence.
- A real browser E2E path covers create Space -> submit
snapshot -> observe result -> close.
- Existing
/browsers/* routes, browser capability tests, Agent Control confirmation, Workbench replay and unrelated tests remain passing.
- SQLite and PostgreSQL migrations pass with one migration head.
Testing Plan
| Layer |
Coverage |
Target |
| Unit |
capability/args validation, redaction, state transitions, event bounds |
>=8 |
| API integration |
lifecycle, Workspace authorization, uniqueness race, idempotency, error mapping |
>=8 |
| Executor integration |
same-Space serialization, distinct-Space concurrency, cancellation/cleanup using a deterministic fake |
>=5 |
| Frontend |
list/create/submit/cancel/close/error states |
>=4 |
| E2E |
real browser create -> snapshot -> close |
>=1 |
Rollback Plan
- Reverse only BrowserSpace/BrowserSpaceTask/BrowserSpaceEvent migrations.
- Disable the new router behind the repository's existing feature-flag mechanism if needed; existing browser routes remain available.
- Before rollback, terminally cancel active Space tasks and release BrowserInstance reservations. Never delete an in-flight runtime without a recorded terminal state.
Out of Scope
- Porting ego-lite's macOS desktop application or packaging model.
- Arbitrary JavaScript, remote-debugging exposure, profile export/import or access to user Chrome tabs.
- Browser context cloning, shared-login cookie copying or multiple Spaces on one physical BrowserInstance. Those require a separate runtime-isolation design.
- Semantic browser search, LLM-generated plans, skill marketplaces or reusable browser skills.
- Persisted Agent conversation history; that is the sibling session-continuity issue.
- Replacing
backend/browser_pool.py, existing BrowserInstance/BrowserBinding models, Agent Control or Workbench.
Related and References
Context
ego-lite demonstrates a useful Agent-browser primitive: each Agent gets a dedicated browser Space instead of sharing one undifferentiated tab pool.
OpenCLI-Razormind already has persistent
BrowserInstance,BrowserBinding, runtime bundles, browser capability manifests and an authenticated capability invocation route. It does not yet have a durable Workspace-scoped ownership boundary saying which Agent may use which browser runtime. This causes ambiguity around parallel Agent work and makes accidental operator-tab reuse difficult to prevent.This is a focused follow-up to #15 and the sibling persistent Global Agent session issue. It is deliberately smaller than ego-lite: Docker/Chromium runtime slots remain the physical browser, and this slice adds the ownership/lifecycle/lease boundary around one dedicated slot. It does not port ego-lite's macOS desktop application or arbitrary JavaScript execution model.
Current State
BrowserInstancestores endpoint, profile, runtime bundle and network policybackend/models/browser.py:33-57browser_capability_service.invoke_capabilityvalidates bundle, host, args and gate before dispatchbackend/services/browser_capability_service.py:83-171backend/api/v1/browsers.py:239-263BrowserSpacemodel or Workspace-scoped Space API existsbackend/models/workbench.py:39-133,backend/api/v1/workbench.py:172-236Goal
Add a durable
BrowserSpacethat reserves exactly one existingBrowserInstancefor one Workspace and owner identity. Add a durable task/lease record for one structured capability invocation at a time.V1 isolation guarantee: one
BrowserInstancecan have at most one active Space. Parallel Spaces therefore require distinct existing browser instances. This is a real, testable guarantee and prevents task/tab collisions without inventing a new browser context protocol.The Space must use the existing Agent/runtime route. It must never attach to an operator's personal Chrome tabs or expose a new public CDP endpoint.
Non-negotiable boundaries
get_request_identity, existing Workspace authorization helpers andbrowser_capability_service.invoke_capability; do not duplicate auth or the CDP client.idle,running,closedorerror; a closed Space rejects new tasks.queued,running,completed,failedorcancelled. Cancellation is idempotent; it may be acknowledged ascancel_requestedinternally but is not reported ascancelleduntil cleanup finishes.409 browser_instance_in_use; never silently share it.Data Model
Create
backend/models/browser_space.pyusingTimestampMixinand the repository's UUID/SQLAlchemy conventions.Constraints:
The active-space uniqueness may use a repository-compatible partial index or a transactionally safe service lock if the migration target cannot express it. The invariant is more important than the exact DDL form.
Do not alter the meaning of existing
BrowserInstance,BrowserBindingorBrowserCapabilityInvocationrows. Existing invocation rows may retain their current audit behavior; new Space rows and events must use the redacted bounded representation above.API Contract
Create
backend/api/v1/browser_spaces.pyand register it inbackend/api/v1/__init__.py. Keep existing/browsers/*routes unchanged.Create request:
{ "browser_instance_id": "existing-instance-id", "binding_id": "optional-existing-binding-id", "owner_type": "operator | runtime_agent", "owner_id": "authorized-opaque-identity", "granted_capabilities": ["snapshot", "navigate"] }Task request:
{ "request_id": "client-generated-idempotency-key", "capability": "snapshot", "args": {}, "timeout_seconds": 60 }Response shape:
{ "data": { "space_id": "opaque-id", "task_id": "opaque-id", "operation_id": "opaque-id", "status": "queued | running | completed | failed | cancelled", "result": {}, "error": null } }Fixed HTTP behavior:
401missing identity.403Workspace/owner authorization failure.404unknown or foreign Space/BrowserInstance/Binding.409active BrowserInstance reservation, closed Space, same-Space active task, or duplicate operation conflict.422invalid request, capability, args or timeout.200list/detail/event/idempotent response.202accepted task that continues asynchronously.Never return raw
BrowserInstance.endpoint,agent_url, cookies, authorization headers, profile paths, credentials or unbounded HTML. Result/event payloads are capped at 64 KiB; oversize output is replaced with{ "truncated": true, "reason": "result_too_large" }.Service and Execution Rules
backend/services/browser_space_service.pyor extend the existing browser service. Routers only parse requests, resolve dependencies and map typed errors.create_spaceauthorizes the Workspace and owner, validates the BrowserInstance/Binding, and atomically reserves the BrowserInstance. A second concurrent create receives409 browser_instance_in_useand creates no Space.submit_taskchecks Space ownership, capability grant and Space status, inserts the idempotent task andqueuedevent, then commits before execution.browser_capability_service.invoke_capability; pass only the validated capability args and existing gate authorization. Do not call CDP directly.queued/runningtask. A second submission returns409 space_task_in_progress; distinct Spaces backed by distinct BrowserInstances may run concurrently.request_idreturns the original task without a second runtime call. Handle the unique-constraint race by reloading the existing task after rollback.cancel_requested, asks the executor to stop, releases the task lease/resources, then recordscancelledonly after acknowledgement. For an already terminal task, return its existing terminal state.failed, set the Space toerroronly when the runtime allocation is unusable, and never return a falsecompletedresponse.(space_id, sequence). Event reads return ascending order afterafter_sequenceand at most 100 rows.BrowserSpaceTask.argsis never returned verbatim. The API may expose only argument keys or a redacted structured result.Frontend Scope
Add a focused Spaces panel to the existing browser operator surface in
frontend/app/(app)/browsers/page.tsxand the existing API endpoint/hooks/types modules:browser_instance_in_use,space_task_in_progressand runtime error codes without offering a shared-tab fallback.Do not add a new top-level AI page or a browser desktop application.
Acceptance Criteria
409 browser_instance_in_usewith no leaked row.409 space_task_in_progress.request_idproduces one task and one runtime call.cancelledbefore executor acknowledgement and cleanup.after_sequence.snapshot-> observe result -> close./browsers/*routes, browser capability tests, Agent Control confirmation, Workbench replay and unrelated tests remain passing.Testing Plan
Rollback Plan
Out of Scope
backend/browser_pool.py, existing BrowserInstance/BrowserBinding models, Agent Control or Workbench.Related and References
backend/models/browser.py,backend/services/browser_capability_service.py,backend/api/v1/browsers.py