mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
e9b6d00ab3
* ✨ 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>
130 lines
4.7 KiB
TypeScript
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();
|