* fix(sandbox): isolate role-required sandboxes per guest, not per agent
Role-forced sandboxing (#129926) contained a guest against the Gateway host
but not against other guests. Sandbox identity stayed agent-scoped, so every
session on a shared agent converged on one scope key, container and
workspace; configured workspaceAccess "rw" additionally pinned the workspace
to the shared agent directory regardless of scope, and configured scope
"shared" used a literal shared container name.
Measured before the fix, two guests on one shared agent:
scope=agent(default) rw -> same scope key, same workspace
scope=agent(default) ro -> same scope key, same workspace
scope=session rw -> distinct keys but SAME workspace
scope=session ro -> isolated
So only one of four combinations isolated guests, and it was not the default.
The authorization layer promised "edit only your own sessions" while the
container layer handed every guest the same writable workspace.
Role-required sessions now derive their sandbox identity from the session
creator: the scope key is principal-qualified, so different guests never
share a container or workspace while one guest's own sessions still do (per
guest, not per session). Configured "rw" resolves to "ro" with an
operator-visible warning, configured "shared" resolves to an effective agent
scope, and a required session with no creator provenance fails closed before
provisioning. Sessions without the requirement keep today's behavior.
* chore: drop accidentally committed sandbox scope repro scratch file
One shared agent could not give different people different execution
isolation: sandbox selection saw only the agent's mode and the session key,
never who created the session. Teams that share an agent had to choose
between sandboxing everyone or no one.
Add a closed, optional role axis sandbox?: "inherit" | "required"
(default "inherit", so existing deployments are unchanged). The Gateway
resolves it from the authenticated session creator and stamps it write-once
onto genuinely new session entries; the agents layer reads that immutable
fact and forces a sandbox even when the agent config says mode: "off".
Anchoring on the creator keeps a session's isolation stable for its lifetime:
a maintainer replying inside a guest session cannot un-sandbox it, and a
caller whose role requires a sandbox is refused inside an unstamped session
rather than silently inheriting host execution. Reset, rewind, rollover and
fast-path all carry the requirement forward so a session cannot be reset out
of its sandbox.
Because exec injects the agent's managed GitHub identity only for gateway-host
execution and blanks GH_TOKEN/GITHUB_TOKEN otherwise, a forced-sandbox session
also loses access to the shared agent's credentials — the property that makes
one shared agent safe for untrusted guests.
Session entries are JSON; no SQLite schema, schema-version, or protocol change.
Docs also correct the sandbox backend list, which omitted the first-class
podman backend.
* feat(gateway): generic operator roles for non-maintainer access
Adds gateway.roles: named role bundles over a closed capability set —
sessions.others (none/view/suggest/write), an agents allowlist, and an
operator-scope ceiling. Roles are person-level (additive user_profiles.role
column, SQLite stays at v9); users.setRole (admin-only) assigns them. With
no gateway.roles config, behavior is unchanged for solo deployments.
Enforcement is deny-by-default from a host-minted actor identity
(system vs operator+profileId on server-only client.internal, never
accepted from the wire) and covers every entry point: WS RPCs, OpenAI-compat
and Responses HTTP, tools invocation, cron, questions, usage, task
suggestions, session catalog/sharing/reads. The agents allowlist gates both
session creation and run-start on existing sessions. Subagent completion
announce and descendant wake mint explicit system authority so role
boundaries never silently drop parent notifications.
The enforcement surface is expressed through a narrow policy vocabulary
(operatorSessionCap, hasOperatorBoundary, authorizeSessionSharing) rather
than per-handler policy internals.
* fix(gateway): heal PR CI after rebase onto main
- Break import cycles: extract GatewayOperatorRoleActor leaf contract; merge
session-group-mutation-targets into session-sharing-target-input.
- Split sessions-suggestions.test.ts (max-lines) into a visibility suite.
- Add users.setRole to the 2026.8 train registry test and regenerate the
Kotlin protocol client.
- Startup UNAVAILABLE gating now precedes session authorization: session
stores are not loaded during startup, so authorization reads would deny
with a misleading non-retryable error.
- sessions.assignOwner keeps its documented visibility-authorized contract
when no operator role caps the caller; view/suggest-capped roles still
cannot reassign foreign session ownership.
- Test stubs updated for main's socket readyState guard (#128144) and the
system-authority arg on channel-native resets.
* test(gateway): chat.send pending-profile dispatch carries its required session target
chat.send requires a non-empty sessionKey at the protocol level; the mutation
pipeline now rejects targetless frames before profile-dependent dispatch, so
the pending-profile test must send a realistic frame.
* fix(browser): resolve upload paths on the owning browser node, not the Gateway
When a browser session is proxied to a remote node, the upload action
previously ran resolveExistingUploadPaths on the Gateway, pinning paths
to a filesystem the node cannot see and rejecting node-local files. The
node-side /hooks/file-chooser route already re-resolves paths against
its own filesystem, so skip Gateway-local resolution whenever the
request is proxied and forward the requested paths as-is.
Fixesopenclaw/openclaw#115251
* fix(browser): transfer uploads to remote browser nodes
* fix(browser): normalize upload abort errors
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>