Network allow list holds only the machine's ticked sites, none by default; strict/standard removed.
The app no longer installs OpenShell; setup is two checks and one step with progress.
DMG build requires grpcio so the sidecar can use OpenShell.
Network: registries and search (strict folded in) with the machine's own hosts, or allow everything.
Credentials list only what was added; shipped entries become presets with the variable each tool reads.
A hung docker info no longer fails the OpenShell checklist.
The OpenShell checklist and setup job show under the OpenShell type; the Windows setup keeps its own routes.
Co-Authored-By: Devika Verma <devikaverma11@gmail.com>
Setup and remove routes prove the wall in a throwaway sandbox before the choice takes effect.
Credential entries are a file or a folder, labelled credential or configuration; collapsible cards.
CI's pytest puts its base temp under /tmp, so the state dir under test was
under /tmp too and `assert not str(private).startswith("/tmp")` failed
there while passing locally with --basetemp outside /tmp. The point of the
check is that the folder is no longer created in tempfile.gettempdir()
itself (OPE-208); assert that instead.
Since 3ca3a67 a provider change rebuilds live sessions in both directions:
switching off closes the sandboxes of idle sessions and rebuilds them
direct; switching on rebuilds direct sessions behind a wall, or refuses
them. The header said "a running session keeps what it started with",
which was true before 3ca3a67 only. Now: "one in the middle of a turn
switches when the turn ends".
Seen 2026-09-28 on WSL: the UI reconnected three sessions 12 s before the
user turned the sandbox on, so their engines were built direct; after the
switch the page said "sessions are refused" while those sessions kept
running commands on the host with the user's full access.
- `SessionManager.apply_sandbox_setting()`: every live engine whose
workspace provider differs from the one a new engine would get is
dropped (its sandbox closed) so the next connection rebuilds it under
the new rule, and is refused if that rule cannot be met; a running turn
is marked stale and rebuilt when it ends (`_refresh_session_tools`).
- `POST /v1/settings/sandbox` calls it when `provider` is in the body and
returns `rebuilt_sessions`.
- The app: SettingsView reports them (`onSandboxProviderChanged`); App
bumps `connectNonce` when the session on screen is among them, so its
socket reconnects and the server rebuilds (or refuses) it at once.
From the fresh-user run: after switching off, two sandboxes stayed Ready
(the sessions that started with them keep them until closed; the new
session ran direct) while the header said "every session"; and the ssh
credential grant stayed in config.toml, invisible on the page. Both are by
design; the words now say so.
Seen in the fresh-user run: the credentials card had no expand marker (its
summary is a flex row for the Add link, which drops the browser's marker),
the editor rendered below the "stored in" line, and the Path field showed
"~/" instead of its example. Now: a drawn marker that turns when open, the
editor inside the card (opening it also opens the card), and an empty path
for a new entry so the ~/.npmrc example shows.
The same-user fallback is gone: without setup the provider refuses and Settings say why (ruling 18).
A shell could not enter a granted folder under the profile; the parents now get a traverse-only entry.
The entry check matches by SID, so it works for a real account too.
From a fresh-user run on WSL (2026-09-27): "Set up sandbox" handed over two
commands, gave no install instructions, and put a Copy button on notes
("found none") and file paths.
- steps carry `command` (run in a terminal, gets Copy) apart from `hint`
(a note) and `docs` (a Guide link). `checks()` prints command or hint.
- `admin_prefix()`: `sudo -n` when this user may sudo without a password,
else `pkexec` (the desktop's own password prompt) when there is a display
and this is not WSL. The app never sees or holds a password.
- `install_openshell()`: NVIDIA's installer as the user on a Mac; on Linux
under the admin prefix with `loginctl enable-linger` FIRST in the same
run, because the installer starts the gateway through the user's systemd
manager, which on WSL does not run until linger is on (it otherwise
prints "could not reach the user systemd manager" and exits 0 without
registering the gateway). The job streams its output as progress and it
can be cancelled.
- No prefix (WSL): the openshell row hands over ONE command, linger + the
installer, and the job stops until "Check again".
- `enable_linger()` tries the user, then the admin prefix, then hands over.
- Terminal `setup`: linger (sudo) before the installer; Guide URL printed.
- Page: "Run this in a terminal on this machine:" above a command; notes
plain, no Copy; Guide links; install progress row; a fixable row shows
its command only once the app could not do it.
- Wording: switch-off text says what it means ("directly on this machine,
with the same access as your user account"); "stored in <path>" only
while the switch is on; the credential editor explains itself with an
npm example and placeholders; "needs download" hint points at the
button, not a terminal command.
The page listed "No sandbox", "macOS sandbox" and "OpenShell" as three equal radio
buttons (with "ready" next to no-sandbox, and "on this Mac" on every OS), and always
showed the network allow list and shared credentials even when they applied to nothing.
Choosing OpenShell did not say what the machine still needed; the user found out when a
session was refused.
Now:
- One switch, "Run agents in a sandbox". Off removes `sandbox_provider` from
config.toml (it used to write "direct", which also disabled a headless machine's
automatic-OpenShell rule); on picks Seatbelt on a Mac, OpenShell elsewhere, with a
dropdown when both exist. On Windows the switch is disabled with the WSL hint.
- Under the switch, for OpenShell: a readiness checklist, the same rows `openworker
machine sandbox status` prints, now with keys and whether the app may fix each one
itself, plus two new rows: free disk space for the image (about 10 GB) and Landlock in
the kernel (OpenShell refuses to start a sandbox without it). Each missing row shows
the command to run, with Copy.
- "Set up sandbox" runs `SetupJob` on the machine's server: it fixes what the app may
(bind-mount config and gateway restart, the config line, linger when no administrator
is needed, the image download with per-layer progress and Cancel) and stops at a step
that needs an administrator or an installer, showing the command; "Check again"
continues. It never runs anything as root and never turns the sandbox off. A desktop
notification says when the download is done.
- Network allow list and shared credentials are collapsible and shown only while the
switch is on.
`setup_cmd` is refactored into `steps()` (rows with keys and fixability) and one
function per fix, used by both the terminal `setup` and the job, so there is one list
of checks. New endpoints: GET /v1/settings/sandbox/readiness, GET/POST
/v1/settings/sandbox/setup, POST /v1/settings/sandbox/setup/cancel.
Copied credentials (and the policy file) went into tempfile.mkdtemp() under /tmp and
were bind-mounted into the sandbox. NVIDIA's installer runs the gateway as a systemd
user service with PrivateTmp=true, so the gateway has its own empty /tmp: the mount
source "does not exist" for it and every session with a shared credential failed to
create. Verified through /proc/<gateway pid>/mountinfo and /proc/<pid>/root/tmp.
The folder is now `<state dir>/sandbox-runtime/ow-openshell-<pid>-<random>/`: on the
real filesystem, owner-only, and a SIBLING of `sandbox/`, never a child, because
`sandbox/` is mounted read-only into every sandbox for the runner and copied keys must
not be readable from another session's sandbox. The server pid in the name lets the
registry's reap sweep the folders of a dead server, which /tmp used to do on its own.
The DOM observer in the browser saw sandbox_preparing arrive on the socket but no row: after switching sessions, connected stays true from the previous socket until the new one's ready, so the !connected guard hid the row. preparingSandbox alone is the condition; ready clears it.
A name reserved and created while 'openshell sandbox list' ran was not in the rows read earlier, so reap() took it for an orphan and deleted it mid-provisioning.
Engine builds run concurrently since OPE-206 (worker threads instead of the event loop).
Every build starts with SandboxRegistry.reap(), which deletes any OpenShell sandbox
carrying our label that no live server owns per the registry. A sandbox was only
recorded after it was connected and verified, so for its first seconds it looked like an
orphan: while one session's sandbox was provisioning, another session's build reaped
it. Seen in the GUI as `openshell sandbox create` failing with "The operation was
cancelled: stream closed", Docker showing create -> start -> kill -> destroy within a
second, and three DeleteSandbox calls after every CreateSandbox.
RunnerWorkspace now records the name with state "creating" before provider.create()
(and before the replacement sandbox in regrant, via a before_create hook on both
providers), removes the row if the create fails, and marks it "ready" once connected.
reap() already treats every row of a live pid as owned, so nothing else changes.
The waiting row for a sandbox build lived in the transcript branch, but a new session
has no items and renders the intro screen instead, so the row never showed where it
mattered most. `idle` is now false while a sandbox is being built.
The `sandbox_ready` transcript notice is dropped: one item made a fresh session
non-idle and hid its intro screen for every sandboxed session. Which wall a session
runs behind belongs in the header (OPE-207).
Review of OPE-205/OPE-206 found three gaps:
- The client reconnects with backoff on ANY socket close, so a session refused for a
missing image would be retried every few seconds, adding an error notice each time
and showing the "reconnecting" strip forever. The server now closes a refused session
with code 4403; the client treats that as final (no reconnect, no strip) and the
error notice in the transcript says why.
- The image check only knew Docker, while docs promise Docker or Podman. It now maps the
gateway's driver to its own tool (`docker`/`podman image inspect`, `... pull`), and
the refusal and the setup hint name that tool.
- A local image store says nothing about a remote gateway's images: when the active
gateway is remote, the check answers "cannot tell" and never refuses.
Connecting a session's socket built its engine on the event loop. With OpenShell that
build creates a container, starts the runner and verifies the mounts: seconds normally,
minutes on first use. While it ran the server answered nothing, the GUI's health checks
failed, and the app fell back to its startup screen ("Starting OpenWorker...") instead of
showing the session. A build that raised (a refused sandbox, a timeout) died in the ASGI
stack and the socket just dropped.
Now the socket handler asks `pending_sandbox_build()` (config only, no preflight) whether
this connect will build a sandbox, sends `sandbox_preparing` if so, runs `get_engine` on
a worker thread, then sends `sandbox_ready` with the workspace's description, or an
`error` event with the reason and a clean close. Engine builds are serialized per session
with a lock, since two connects could now build concurrently and with OpenShell each
build is a container.
The GUI renders the two events: a waiting row "Preparing the openshell sandbox for this
session..." until `ready`, and one quiet transcript line naming the wall once it is up.
A refusal lands in the session view as the usual error notice.
On first use of the OpenShell sandbox, `openshell sandbox create` pulls the pinned base
image (about 5 GB). OpenWorker waited for that create with a 600 s timeout, inside the
session-start request; on a slow link the pull outlived it, the session failed with
`TimeoutExpired`, the UI showed "Starting OpenWorker..." and retried, and nothing told
the user a one-time download was the cause. Settings > Sandbox said "ready" throughout.
Now the image is part of the readiness check, keyed on the gateway's compute driver
(`openshell gateway info`), not the operating system, since the same image serves
Linux, WSL and macOS and only the Docker driver can be asked:
- `preflight()` raises `OpenShellImageMissing` (an `OpenShellUnavailable`) with the
`docker pull` command when the driver is Docker and the image is absent, so a session
is refused in a second with a message that says what to do.
- Settings > Sandbox shows OpenShell as "needs download" with a hint, and keeps it
selectable; `state` joins each provider row of /v1/settings/sandbox.
- `openworker machine sandbox status` gets a row for the image; `setup` offers the pull
last, with Docker's own progress and no time limit, on macOS too.
A server killed hard left its owr-* folder behind; the next full-mode start removes folders whose pipe is dead.
The rule name carried a comma the remove script split on; renamed, and old rules go by wildcard.
One list per platform (coworker/sandbox/toolchains.py), machine-level, editable by name; read entries on Windows,
the Seatbelt profile on the Mac. The Windows full mode passes its environment through a file (1024-character logon limit).
Setup creates OWSandboxOpenNet (no rules) and OWSandboxClosedNet (outbound blocked, loopback closed
except the proxy's port range by WFP filters); the network profile picks the account.
The provider proves the loopback wall from inside before a session starts.
Any host, no proxy; the files stay confined. Strict remains the default elsewhere.
Settings offers it as a third choice; credential rows hide their hosts when it is on.
Copies go into the account's own profile through the daemon, so the account owns them; the runner's new `connect` command is ssh's ProxyCommand where there is no nc; a graceful shutdown removes them.
The same-user mode keeps the file-and-variable grants and tells the agent the ssh key needs the full sandbox.
`sandbox setup` (one administrator prompt) creates the account, hides it, denies remote and network logon, blocks its outbound network by firewall rule and stores its password for the user only.
The daemon is logged on as that account on its own desktop with entries on the session's folders; the profile is out of reach; the internet is reached only through the allow-list proxy.
One made-up session SID restricts the daemon's token and gets a Modify entry on the session's folders; entries move without a restart and go at close.
Reads and the network are not limited in this mode and describe() says so; the full mode with a hidden account follows.
The pipe's security descriptor admits only the accounts the daemon is given; the runner and shell tests run on Windows.
The registry's liveness probe no longer uses os.kill(pid, 0), which terminates a process on Windows.
openworker-server sandbox-runner serve|attach loads only the runner package. The spec ships the runner and toolkit sources as data, so the zipapp can be packed from the frozen build.
Machine-level, read and written through /v1/settings/sandbox into the machine's config.toml.
Adds the sandbox_network_profile setting; strings in English and Chinese.
A machine-level list (ssh, gh, aws, kube to start); enabled entries are copied into the sandbox's private home and their hosts join the allow list.
ssh and git are pointed at the copy, and ssh tunnels through the proxy; the agent is told what it was given.