Files
lobehub__lobehub/packages/device-sandbox/src/runtime.ts
LiJian e9b6d00ab3 ✨ feat(device): add a Local Sandbox execution environment (#18143)
* ✨ feat(device): add a Local Sandbox execution environment

Adds a "Local sandbox" row to the execution-environment switcher, next to
"Local device". Picking it runs the agent on the same machine, but confines
its shell commands: writes limited to the run's working directory plus the OS
temp dir, and no network.

The sandbox backend (`@lobechat/device-sandbox`, SRT) has been in the tree
since #17476 as an opt-in with no caller. This wires it to a user-facing
choice.

Modelled as `agencyConfig.localSandbox`, a modifier on `executionTarget:
'local'`, rather than a sixth `DeviceExecutionTarget`. Every existing routing
rule — web coercion, gateway upgrade, bot-trigger promotion, fixed-workspace
policy — stays literally unchanged, and the flag composes if sandboxed
execution later extends to `device` targets.

Both execution paths converge on `isLocalSandboxEnabled(config, target)`, so
they cannot disagree about which runs are fenced:

- gateway-routed runs: `ServerToolTransport` resolves it against the run's
  execution plan, the Local System device-proxy forwards it to `runCommand`
- in-process desktop runs: resolved in the client tool context

The stored flag alone never decides — it applies only once the effective
target actually resolved to `local`, so a config that landed on
`sandbox`/`device` is never reported as fenced.

Fails closed throughout. `onUnavailable: 'deny'` means an unavailable backend
aborts the command instead of silently running it unconfined; a sandboxed run
with no working directory is refused rather than confined to the app install
directory; and the picker hides the row entirely unless the host reports a
working backend (SRT supports macOS and Linux only, and Linux depends on host
binaries that may be missing).

Scope, deliberately: shell commands only. File tools run in the desktop
process, `lh` keeps its in-app route (it needs the credentials and network the
sandbox strips), and heterogeneous CLI agents spawn through their own path.
User-facing copy says "commands", never "the agent".

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

* ✨ feat(device): add a network toggle to the Local Sandbox, and stop lying about availability

Three changes, all driven by running this on a real Windows host.

**The platform claim was wrong.** SRT supports Windows — `isSupportedPlatform()`
returns true on win32; it needs a one-time `windows-install` that provisions a
dedicated `srt-sandbox` account and a user-SID-keyed WFP filter set. Comments
and copy that said "macOS and Linux only" are corrected.

**The row is disabled, not hidden, when the host can't sandbox.** On Windows
"unavailable" usually means "not installed yet", so hiding the feature strands
the user with no way to find out why it's missing. It now renders disabled with
the host's own reason, matching how the Cloud Sandbox row already handles an
unsupported provider.

**The cheap probe is not the whole truth.** `checkDependencies()` only verifies
the backend is installed; the egress fence and the secondary-logon spawn are
exercised only when the first real process starts. Observed on a real machine:
probe reports available, then every command fails at
`CreateProcessWithLogonW`. `runCommand` now reports a failure to *establish*
the fence (never a command's own non-zero exit) through `onSandboxUnavailable`,
the desktop downgrades its verdict for the rest of the app run, and the picker
revalidates on open — so a broken host greys the row out after one attempt
instead of offering an environment that can only fail.

**Network toggle.** A switch on the Local Sandbox row, default off, opening the
package-registry allowlist (npm / PyPI / crates.io / RubyGems / Go / GitHub /
GitLab). It cannot be "allow everything": the backend's schema rejects a bare
`*` and patterns like `*.com` outright, so network access is always some
allowlist. Copy says so — "common developer domains", never "allow network" —
and the hover tip names what is opened and states the limitation. Stored as
`agencyConfig.localSandboxNetwork`, a separate field from `localSandbox` so the
choice survives toggling the sandbox off and back on, and threaded through both
execution paths alongside it.

Toggling the switch deliberately does not change which environment is selected:
same dormant semantics the sandbox flag already has, so nothing switches behind
the user and the popover stays put.

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

* ✨ feat(device): let the desktop app set the sandbox up itself

Installing the desktop app should be enough to get the Local Sandbox — a user
should never have to find and run `npx sandbox-runtime windows-install` before
a product feature works. Two things stood between them and that.

**A dead-end row.** When the backend was missing, the row was disabled with a
diagnostic and nothing else. It now carries a "Set up" button that provisions
the backend on their machine: one elevation prompt, idempotent, and the row
becomes usable without leaving the popover. Only offered where the app can
actually deliver — Windows. macOS needs nothing (Seatbelt ships with the OS),
and Linux needs a distro package only the system's package manager may install,
so that case shows the command to run instead of a button that would lie.

Dismissing the elevation prompt comes back as `cancelled`, not an error: the
user declined, so nothing is reported and the UI stays as it was. Setup also
clears the sticky capability downgrade — it is the one thing that must be able
to lift it, or the button could never work on the very hosts that need it.

Setup never runs implicitly. Creating a Windows account and installing network
filters is not something to do behind someone's back, so it hangs off an
explicit click.

**The binary would not have shipped.** `@anthropic-ai/sandbox-runtime` was
neither an external runtime module nor asar-unpacked, while `files` excludes
node_modules wholesale — so a packaged build would have contained no
`srt-win.exe` at all. The probe would have failed, and the new button with it,
on every installed copy while working fine in dev. It is now a direct
dependency, externalized from the main bundle, and its `vendor/` helpers are
unpacked from asar (a file inside app.asar has no real path to spawn).

Verified on a real Windows host: the runtime is externalized rather than
inlined, the vendor binary resolves from the desktop package, and both the
files config and the unpack patterns pick it up.

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

* ✨ feat(device): report whether a command was actually sandboxed

The execution-environment chip shows what the user *chose*. Nothing showed what
a run actually *did* — so a command that executed unfenced looked exactly like
one the sandbox confined.

That is not hypothetical. A desktop bound to a server without the sandbox
injection runs `local` commands through the device gateway with no sandbox flag
at all: the command succeeds, unconfined, while the chip still reads "Local
sandbox". A security property nobody can observe is one nobody should trust.

`createSandboxLaunchPlan` already knows the answer — `SandboxLaunchPlan.sandboxed`
— and it was being discarded. It now rides back on `RunCommandResult` and into
`RunCommandState`, so the run itself can state the outcome instead of the
request restating its own intent.

Deliberately tri-state: `undefined` for a request that never asked for a
sandbox, so "not fenced" and "never wanted fencing" stay distinguishable.

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

* 🐛 fix(device): make the sandbox actually work on Windows

Verified end to end on a real Windows host for the first time. Three defects
stood between the feature and a working fence; all three are invisible on macOS,
which is the only platform the backend integration was ever exercised on.

**1. The helper was unreadable by the sandbox account.** The sandbox runs the
child as a dedicated `srt-sandbox` local user, so every binary in the launch
chain must be readable by *that* account. The desktop installs per-user by
default, putting `srt-win.exe` under `C:\Users\<name>\AppData\Local\…` — a tree
no other local account may read. `CreateProcessWithLogonW` logged the sandbox
user on successfully (confirmed by Security event 4624) and then failed at
process creation with a bare ACCESS_DENIED, which reads like a broken sandbox
rather than a file-permission problem. The helper is now staged into
`%PROGRAMDATA%\LobeHub\sandbox\srt-win-<version>\`, whose default ACL grants
`BUILTIN\Users` read+execute, and the backend is pointed at it through the
`windows.srtWin.path` override it provides for exactly this. Version-scoped so
an app update cannot leave a stale helper behind. Granting an ACE on the install
directory would have worked too, but a per-user install directory is replaced
wholesale on update, taking the ACE with it and silently breaking the sandbox
again.

**2. The wrapped command ran under a hardcoded `/bin/sh`.** There is no such
path on Windows, so every launch died with
`CreateProcessAsUserW(/bin/sh): path not found` even once the fence came up.
The caller already resolved the right shell for the platform (`getShellConfig`
→ Git Bash / PowerShell / cmd, command last), so the sandboxed spawn now reuses
it and matches the unsandboxed one exactly, flags included.

**3. One policy per process made the sandbox usable once per app launch.** The
backend holds a single policy, and the working directory is part of its
fingerprint — so switching agents, opening a second project, or flipping the
network toggle all hit SANDBOX_POLICY_CONFLICT. The runtime now resets and
re-initializes when the policy changes and nothing is running; a live command's
fence is still never swapped out from under it, so a busy runtime keeps
refusing (loudly) instead.

Real-host results, driving the product path (`runCommand` +
`createLocalSandboxPolicy`) with the process cwd deliberately left under the
user profile:

  identity        whoami → srt-sandbox
  write inside    exit 0, file created
  write outside   exit 1, access denied by the OS
  secret env      LOBE_SECRET_CANARY → empty
  network off     curl exit 56, http 000
  network on      github.com → 200, example.com → 000
  sandboxed=true reported on every run

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

* 🐛 fix(device): resolve the client sandbox decision the way every other layer does

Review follow-up.

The in-process desktop path merged the member override with a plain
`resolveAgencyConfig`, while the picker and server dispatch both go through
`resolveAgentAgencyConfig` — which ignores that override for a private Agent,
and for an author/admin of a public workspace Agent. A stale override could
therefore run a command unfenced while the chip still read "Local sandbox":
precisely the claim-without-the-guarantee this feature exists to prevent.

It now uses the same resolver. One input is genuinely unavailable here —
`canManage` comes from a permission hook with no store to read synchronously —
so rather than guess, both readings are computed and a disagreement resolves
toward fencing. Over-fencing fails loudly and recoverably (a write outside the
working directory is refused with a clear error); under-fencing is silent and
unsafe. The network allowance takes the opposite rule and requires agreement,
since it *widens* what a fenced command may reach.

Also pins the fence root. The sandbox policy is built from `params.cwd` on the
device, so whoever controls that controls what the command is confined to.
Both injection sites already strip the model's own `cwd` and re-inject the run's
configured working directory, but nothing guarded it: a regression test now
holds the server device-proxy to it, and the desktop records why it may trust
the value it receives.

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

* 🐛 fix(desktop): ship the sandbox helper as a resource, not a runtime dependency

Found by actually running `package:win`, which the previous approach never
survived: making `@anthropic-ai/sandbox-runtime` a production dependency pulls
its four transitive packages into electron-builder's node_modules traversal, and
pnpm's layout does not satisfy it —

  ⨯ Production dependency @pondwader/socks5-server not found
    for package @anthropic-ai/sandbox-runtime

Its JavaScript never needed to be external; Vite bundles it into the main
process fine. Only the helper binaries have to exist as real files, because the
sandbox spawns them by path. They now ship via `extraResources`, which sidesteps
dependency traversal entirely and drops the asar-unpack rule with it.

That relocation breaks the backend's own `getSrtWinPath()`, which resolves
relative to its package directory — a path that stops existing once the module
is bundled. `resolveSrtWinSource` replaces it with an explicit chain:
`LOBE_SRT_WIN_PATH`, then `resourcesPath/sandbox-runtime/vendor/...` for a
packaged app, then the backend's own lookup for development and tests, where
`process.resourcesPath` is undefined.

Verified against the produced `win-unpacked`: the binary sits at
`resources/sandbox-runtime/vendor/srt-win/x64/srt-win.exe`, the bundled main
process carries the resolution branch that looks exactly there, and the backend
JS is inlined as expected. (The NSIS step then failed downloading its toolchain
— a network timeout in this environment, unrelated.)

Also names ripgrep alongside bubblewrap in the Linux instructions: the backend
errors on either being missing, and listing one sends the user round twice.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:13:42 +08:00

130 lines
4.7 KiB
TypeScript

import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
import { createSandboxEnv } from './env';
import { createSrtConfig } from './srt';
import type { CreateSandboxLaunchPlanOptions, SandboxCapability, SandboxLaunchPlan } from './types';
import { SandboxError } from './types';
const quoteShellArgument = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`;
const serializeCommand = (command: { args: string[]; cmd: string }): string =>
[command.cmd, ...command.args].map(quoteShellArgument).join(' ');
const configFingerprint = (config: SandboxRuntimeConfig): string => JSON.stringify(config);
export class SrtSandboxRuntime {
private activeCommands = 0;
private initialization?: Promise<void>;
private initializedFingerprint?: string;
private async ensureInitialized(config: SandboxRuntimeConfig): Promise<void> {
const requestedFingerprint = configFingerprint(config);
if (this.initialization) await this.initialization;
if (this.initializedFingerprint) {
if (this.initializedFingerprint === requestedFingerprint) return;
// The backend holds ONE policy per process, and the working directory is
// part of it — so switching agents, opening a second project, or flipping
// the network toggle all arrive here. Refusing them would make the
// sandbox usable exactly once per app launch.
//
// Re-initializing is safe only while nothing is running under the old
// policy: a live command's fence must never be swapped out from under it.
// `shutdown` enforces that and throws SANDBOX_BUSY otherwise, which is the
// honest answer — the caller retries once the other command finishes.
if (this.activeCommands > 0) {
throw new SandboxError(
'SANDBOX_POLICY_CONFLICT',
`Sandbox Runtime is busy with ${this.activeCommands} command(s) under a different device policy`,
);
}
await this.shutdown();
}
this.initialization = SandboxManager.initialize(config, undefined, true);
try {
await this.initialization;
const initializedConfig = SandboxManager.getConfig();
if (!initializedConfig || configFingerprint(initializedConfig) !== requestedFingerprint) {
throw new SandboxError(
'SANDBOX_POLICY_CONFLICT',
'Sandbox Runtime is owned by another caller with a different device policy',
);
}
this.initializedFingerprint = requestedFingerprint;
} finally {
this.initialization = undefined;
}
}
async createLaunchPlan(
options: CreateSandboxLaunchPlanOptions,
capability: SandboxCapability,
): Promise<SandboxLaunchPlan> {
const config = createSrtConfig(options.policy);
await this.ensureInitialized(config);
// The shell the sandboxed child runs under has to be one that exists on
// this OS. `/bin/sh` was hardcoded here, which is why every Windows launch
// died with `CreateProcessAsUserW(/bin/sh): path not found` even after the
// fence itself came up cleanly.
//
// The caller already resolved the right shell for the platform
// (`getShellConfig` → Git Bash / PowerShell / cmd, with the command as the
// final argv entry), so reuse it rather than guessing again: the sandboxed
// spawn then matches the unsandboxed one exactly, flags included.
const { args, cmd } = options.command;
const [command, binShell] =
process.platform === 'win32'
? [args.at(-1) ?? '', { args: args.slice(0, -1), exe: cmd }]
: [serializeCommand(options.command), '/bin/sh'];
const wrapped = await SandboxManager.wrapWithSandboxArgv(
command,
binShell,
undefined,
undefined,
options.cwd ?? process.cwd(),
);
this.activeCommands += 1;
let released = false;
return {
args: wrapped.argv.slice(1),
capability,
cmd: wrapped.argv[0],
env:
process.platform === 'win32'
? wrapped.env
: createSandboxEnv(options.env ?? process.env, options.policy),
release: () => {
if (released) return;
released = true;
this.activeCommands -= 1;
SandboxManager.cleanupAfterCommand();
},
sandboxed: true,
};
}
async shutdown(): Promise<void> {
if (this.initialization) await this.initialization;
if (this.activeCommands > 0) {
throw new SandboxError(
'SANDBOX_BUSY',
`Cannot reset Sandbox Runtime while ${this.activeCommands} command(s) are active`,
);
}
if (SandboxManager.isSandboxingEnabled()) await SandboxManager.reset();
this.initializedFingerprint = undefined;
}
}
export const srtSandboxRuntime = new SrtSandboxRuntime();