Commit Graph

21 Commits

Author SHA1 Message Date
YuTengjing 4367a885eb 🐛 fix: clarify artifact delivery and preserve tool refusals (#19409) 2026-09-14 10:25:04 +08:00
YuTengjing 6261fa7f84 🐛 fix: surface sandbox recreation in tool results (#19329) 2026-09-09 18:02:25 +08:00
Arvin Xu 51b7420c95 🐛 fix(local-system): report computer-tool failures as failures, and refuse ambiguous edits (#18539)
* 🐛 fix(local-system): report computer-tool failures as failures

`ComputerRuntime.errorOutput` hardcoded `success: true` and left `error`
unset, so every failing file/shell operation was recorded as a successful
call. The visible symptom is `editFile`: an `old_string` that isn't in the
file changes nothing, yet the trace inspector prints ✓,
`usage.tools.byTool[].errors` stays 0, and the EditLocalFile renderer —
which already branches on `pluginError` to draw an "Edit Failed" alert —
gets neither a diff nor an error and renders an empty card. 790 such
edits in the last 7 days (~100/day) all counted as successes.

Report the failure on both fields. `content` is untouched, and
`ToolMessageReorder` prefers a non-empty `content` over `pluginError`, so
the text the model reads does not change. `error` is a fresh `{ message }`
rather than the service's own object: `executeToolWithRetry` escalates on
`error.kind === 'retry'`, and these calls were never retried while they
claimed success.

Also give `editFile` a reason instead of a bare "not found". The miss is
almost always re-indented text, a case difference, or content that drifted
since the last read — all decidable from the file already in hand. Naming
the cause saves the agent a readFile + LLM round trip, which at a 190k
context costs 30-60s of wall clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(local-system): refuse an ambiguous editFile instead of guessing

With `replace_all` unset, an `old_string` matching in several places was
resolved by taking the first match and reporting "Successfully replaced 1
occurrence(s)" — the caller never learned there was a choice, let alone
that the edit may have landed on the wrong occurrence. Silently editing
the wrong line is worse than not editing at all, because nothing
downstream flags it.

Refuse the call, name the matching lines, and state the two ways out
(more surrounding context, or `replace_all: true`). The manifest and the
system prompt now carry the same contract so the model writes a unique
`old_string` in the first place rather than discovering the rule from an
error.

Also fix two CLI assertions that pinned the old "failure reported as
success" envelope for getCommandOutput / killCommand against an unknown
shell id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(local-system): stop previewing an edit the runtime will refuse

The approval card rendered `String.replace`'s first-match diff and
labelled it "Replace first occurrence only", so with a non-unique
`old_string` the user could approve a concrete diff that the runtime then
refuses without touching the file. Count the matches in the preview and
show the reason plus the two remedies instead.

The new copy takes a plain `times` variable rather than `count`: i18next
reads `count` as a plural selector and would look for `_one` / `_other`
keys that do not exist.

Also update the desktop `LocalFileCtr` regressions that pinned the old
behaviour — one supplied two `Hello` matches and expected the first to be
written, the other compared the not-found message by exact string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:37:14 +08:00
LiJian 95dfa1d389 🐛 fix(device): probe the sandbox helper this app ships, not the one the backend guesses (#18176)
* 🐛 fix(device): probe the helper this app ships, not the one the backend guesses

Reported from an installed canary build: the Local Sandbox row said

  Sandbox Runtime dependencies are unavailable: srt-win.exe not found.
  Looked in: …\resources\app.asar\vendor\srt-win\x64\srt-win.exe

while `resources/sandbox-runtime/vendor/srt-win/x64/srt-win.exe` was sitting
right there. The Set-up button was equally useless, because provisioning was
never the thing that was missing.

`probeSandboxCapability` went through `SandboxManager.checkDependencies()`,
which resolves the helper via the backend's own `getSrtWinPath()` — a path
relative to the backend's package directory. Bundling that package into the
main process makes the computed path land inside `app.asar`, where no file can
exist. The launch path was already immune (it passes `windows.srtWin.path`);
only the probe still asked the wrong question, so a correctly installed app
reported a missing binary and offered a button that could not change the answer.

Windows now runs the checks directly against the resolved path, mirroring the
backend's own sequence — binary present, sandbox user provisioned with a
readable credential, WFP filters installed — and stages the helper first, so
the path it validates is the one the launch will use. `cannot-read` on filter
enumeration stays a non-failure: it is admin-gated, and the behavioural egress
check at `initialize()` is what fails closed.

macOS and Linux keep the backend's own check. Neither resolves anything
relative to the app, so neither was affected.

Verified against the installed build's own layout: the probe that reported
"not found" now returns available.

Two follow-ons:

- ship the backend's package.json beside the binaries, so the staging directory
  stays keyed on the real version instead of falling back to the binary's size —
  the fallback worked, but defeated per-version isolation in exactly the case it
  exists for, an installed app being updated;
- make the `createSrtConfig` assertion platform-aware, since the Windows helper
  override is absent everywhere else and an exact-match expectation would fail
  on one platform or the other.

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

* 🐛 fix(device): make one function decide the sandbox helper path

Review follow-up on both findings.

**Probe and launch could disagree.** The probe fell back to the unstaged source
when staging failed, while `createSrtConfig` fell back to *no override at all* —
letting the backend resolve its own package-relative path, which is inside
`app.asar` once bundled. A host where staging cannot happen (ProgramData locked
down, disk full) was therefore advertised as available and failed on the first
command: exactly the "claims a guarantee it never applied" outcome this feature
exists to avoid.

`resolveEffectiveSrtWin` is now the single place that resolves and stages, and
the probe, the launch, and setup all call it. They cannot diverge by
construction. It falls back to the unstaged source rather than giving up,
because a per-machine install already sits somewhere the sandbox account can
read — staging is an accommodation for per-user installs, not a precondition.

**The probe had no test.** The previous commit changed `probeSandboxCapability`
but only asserted on `createSrtConfig`, which already used the staging helper —
so putting the probe back on `SandboxManager.checkDependencies()` would have
gone unnoticed, and AGENTS.md asks for a regression test that fails before the
fix. Added one covering the Windows branch directly: it pins that the backend
check is *not* consulted, that the resolved path is what gets validated, that an
unprovisioned account stays distinguishable from a missing binary (only one of
those is what the Set-up button fixes), that `cannot-read` filter enumeration is
not a failure, and that other platforms keep the backend check. Five of the eight
fail against the pre-fix implementation.

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

* 🐛 fix(tool-runtime): stop runCommand swallowing the reason it failed

Every sandboxed command came back as
`[UNKNOWN_EXEC_ERROR] Tool execution failed`, with the actual reason discarded.

`normalizeResult` put the daemon's `error` inside `result.result.error`, but
`ComputerRuntime.errorOutput` reads the ServiceResult's own `error`, then
`state.stderr`, then `state.error`. A command that fails *before spawning* fills
none of those — there is no process, so no stderr and no exit code — so the
chain ran to its last-resort fallback every time.

That is the exact shape of every Local Sandbox refusal ("requires a working
directory", "unavailable on this device"), which is how a precise, actionable
message turned into a generic one that told the user nothing and gave the model
nothing to correct. The same gap was already fixed for editLocalFile / grep /
glob; runCommand was missed.

Surfaces `raw.error` at the top level like those tools do, and pins both halves:
a pre-spawn failure keeps its reason, and a successful run still reports whether
it was actually sandboxed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:01:21 +08:00
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
YuTengjing 20afc09c71 ♻️ refactor(local-system): consolidate tool dispatch into a shared runtime entry with cwd injection (#17982) 2026-08-08 18:53:30 +08:00
Arvin Xu 3295b933cc 🐛 fix(local-system): guard oversized glob and spawn errors (#17792)
* 🐛 fix(local-system): guard oversized glob and spawn errors

* 🐛 fix(local-system): scope glob cap to agent runtime
2026-07-31 13:09:39 -07:00
Arvin Xu f4cebaa562 🐛 fix(builtin-tool): fix local-system grep always returning 0 matches (#17388)
* 🐛 fix(builtin-tool): fix local-system grep always returning 0 matches

The builtin local-system grepContent tool consistently returned 0 matches
even when content existed, across all models. Two model-independent plumbing
bugs were responsible:

1. Search root fell through to process.cwd(). The grep systemRole promises the
   model that `scope` "defaults to the working directory" when omitted, but
   nothing injected the effective working directory. An omitted or relative
   (`.`) scope fell all the way through to the Electron main process
   `process.cwd()` (`/` in a packaged app), so every scope-less grep searched
   the wrong directory. Now the executor resolves the search root against
   `ctx.workingDirectory` — sourced from the same place as the
   `{{workingDirectory}}` prompt placeholder — so search and prompt agree.

2. Params stripped at the IPC boundary. `denormalizeParams` collapsed grep args
   to `{cwd, filePattern, output_mode, pattern}`, dropping `-i/-n/-A/-B/-C/glob/
   type/multiline/head_limit` and renaming `glob`->`filePattern` (a field the
   desktop `buildGrepArgs` never reads). Case-insensitive / typed / glob-scoped
   searches silently lost their flags. Now the full param set is forwarded.

Regression tests exercise the real callService->denormalizeParams path and the
omitted/relative/absolute scope resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(builtin-tool): bind grep default directory to the executing topic

The grep working-directory fallback read global active-topic state via
`getEffectiveWorkingDirectoryPath()`. When a response started in topic A is
still streaming and the user switches to topic B before its grep tool call
arrives, the prompt was generated with A's directory but the call resolved
against B's — potentially searching the wrong project.

Resolve the working directory from the topic captured at request time
(`topicId ?? operation?.context?.topicId`, agent fallback unchanged) instead of
the active topic. Adds `topicSelectors.getTopicWorkingDirectory(id)` (by-id,
active-topic fallback when no id) and threads the captured topicId through
`getEffectiveWorkingDirectoryPath(topicId)` from client tool execution. Prompt
build still passes no id (active topic is correct at generation time).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:03:34 +08:00
Arvin Xu 8284a6e2b2 ✨ feat(local-system): let readFile return images so vision models can see them (#16912)
* ✨ feat(local-system): let readFile return images so vision models can see them

Tool-returned images previously reached the LLM only as `![](url)` text —
the model never actually saw them. Wire the builtin readFile path so an
image file becomes a real `image_url` content part (LOBE-6801 builtin path;
mirrors CC Read-on-image LOBE-7786 and MCP resource image #13618).

- context-engine MessageContent: new processToolMessage reads
  pluginState.images → image_url parts (vision) or a downgrade placeholder,
  keeping tool_call_id so providers can pair the result with its call.
- tool-runtime: ComputerRuntime.readFile detects isImage → state.images;
  LocalSystemExecutionRuntime normalizeResult passes isImage/previewUrl;
  ReadFileState gains images (+ ToolResultImage type).
- desktop LocalFileCtr.readFile: image branch resolves a desktop preview
  URL via localFileProtocolManager (local-file-shell refuses binary), so
  DB stores a small URL instead of base64; LocalReadFileResult gains
  isImage/previewUrl.
- ReadLocalFile renderer: PreviewGroup thumbnails when state.images present.

Desktop-only for now; CLI/cloud-sandbox readLocalFile still refuses binary.

Co-Authored-By: Claude <noreply@anthropic.com>

* ♻️ refactor(local-system): upload readFile images instead of localfile:// preview URLs

The previous approach persisted desktop-only `localfile://` preview URLs
(5-min in-memory tokens) into pluginState.images and expected the
server-side send path to fetch them — neither holds. Rework the chain to
the same convention as the hetero CC Read-on-image echo (#16786):

- LocalFileCtr.readFile returns the image bytes as base64 (`imageData`),
  dropping createPreviewUrl and the dirname(path) workspaceRoot guess that
  rejected any image nested below an approved workspace root; paths now
  resolve via local-file-shell's resolveAgainstCwd. Formats limited to
  png/jpeg/gif/webp (what vision providers accept; svg reads as text) with
  a 10MB cap.
- ComputerRuntime routes the bytes onto state.images as pre-upload
  `{ data, mediaType }` entries; the renderer uploads them to file storage
  (uploadToolResultImages) and rewrites to `{ fileId, url }` BEFORE
  persistence, degrading to the `[Image: …]` placeholder on failure. The
  gateway path strips the bytes (no upload client in main yet, same
  deferral as #16786).
- MessageContent.processToolMessage only forwards entries with fetchable
  http(s)/data URLs.
- OpenAI builders no longer emit image parts inside tool messages (the API
  rejects them): Chat Completions flattens the tool message to text and
  re-attaches images as a user message after the contiguous tool batch;
  Responses keeps function_call_output textual and appends an input_image
  user item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ♻️ refactor(desktop): upload readFile images from the main process

Move the upload out of the renderer persistence hook into main, mirroring
the CLI's uploadFileBuffer (hash dedup via file.checkFileHash → pre-signed
S3 PUT → file.createFile) over the same raw Oidc-Auth fetch pattern
GatewayConnectionCtr already uses for agentNotify/device.register.

- New RemoteFileUploadService (main): buffer → durable { id, url }; declines
  when no active remote session.
- LocalFileCtr.readFile uploads right after reading the bytes and returns
  imageFileId/imageUrl — base64 never crosses IPC; on upload failure the
  content degrades to an "[Image: …] (upload unavailable)" placeholder.
- ComputerRuntime emits already-uploaded state.images entries; ToolResultImage
  loses the pre-upload `data` field (url required again).
- Drop the renderer-side uploadToolResultImages step and the gateway
  imageData strip — the gateway path now gets working image references too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ♻️ refactor(desktop): delegate readFile image upload to the embedded CLI

Replace the hand-rolled raw-fetch TRPC upload flow with `lh file upload
<path> --json` — the CLI already owns the whole pipeline (hash dedup,
pre-signed S3 PUT, file record). Run its embedded script with the app's own
binary via ELECTRON_RUN_AS_NODE (what the generated wrapper does), execFile
with an args array so the agent-controlled path never touches a shell, and
inject the desktop session through LOBEHUB_JWT/LOBEHUB_SERVER like CliCtr
and the hetero spawn paths; without a session lh falls back to its own
login. LocalFileCtr now passes the resolved path instead of reading bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-11 23:42:59 +08:00
Rylan Cai 0746a125a9 ⚡️ perf: shell output optimization (#15777)
* wip: add impl

* ♻️ refactor: tighten shell output file changes

* 📝 docs: trim shell output wording

* ⚡️ pipe shell output to a single temp file

* 🐛 wait for shell output close before final snapshot

* 🧹 remove shell output byte delta tracking

* 🧹 derive shell running state in runtime

* 🧹 remove unused shell output truncation helper

* ⚡️ pipe shell output through stdio fd

* 🧹 keep background shell output lazy

* 🧹 simplify shell output root option

* 🐛 kill shell process trees

* 🧹 simplify shell output paths

* 🧹 unify shell output contract

* 🐛 ignore stdin for content search commands

* 🐛 add search timeouts

* 🧹 align grep timeout handling

* Revert "🧹 align grep timeout handling"

This reverts commit 48ad495d7facf4fdbe9be9bc554ff3486b22a298.

* Revert "🐛 add search timeouts"

This reverts commit 7e097fd04a2ea7c3598e01e837d46542eb5be6e8.

* 🧹 restore grepContent path resolution

* 💬 guide search scopes to cwd

* 💬 remove stale command output delta prompt

* 🧹 simplify shell output dir handling

* ♻️ track shell close as lifecycle promise

* ♻️ inline shell output date formatting

* ♻️ inline shell output helpers

* ♻️ align shell spawn error handling

* ♻️ simplify output preview arguments

* 💬 simplify command output prompt

* ♻️ clarify command result state formatting

* ♻️ inline command running check

* ♻️ minimize command runtime state diff

* 💬 simplify command result output text

* 💬 avoid duplicate command output file hints

* 🐛 preserve grepContent scope in local runtime

* ✅ align shell command controller tests

* ✅ document shell output lifecycle tests

* ✅ clarify nested shell kill test

* ✅ remove obsolete shell truncation tests

* 🐛 support grep content output mode

* 💬 show shell output file size

* ⚡️ increase shell output preview budget

* 🐛 sum grep count matches

* 🐛 update command output callers

* ✅ update shell output tests

* 🐛 update desktop shell command output

* 🐛 fix desktop os mock tmpdir

* ✨ split shell stdout stderr outputs

* ♻️ clean shell output compatibility fields

* ♻️ tighten shell output file diff

* ♻️ remove command result output param

* ♻️ remove get command new output state

* 🐛 fix cli grep content scope forwarding

* 🐛 preserve cli grep content params

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-07-01 12:21:14 +08:00
Rylan Cai 78d34d076e 🐛 fix(cli): cap local file search factory results (#16281)
* 🐛 fix(cli): cap local file search factory results

* ♻️ refactor(cli): keep local file search naming stable

* 🧪 test(cli): cover local file search limit forwarding

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-06-25 17:57:32 +08:00
Arvin Xu 825cfc2189 🐛 fix(device): tolerate CRLF line endings when editing files & surface real edit errors (#16061) 2026-06-20 01:15:22 +08:00
Rylan Cai 99023811d8 📝 fix: clarify local system shell result wording (#15745)
* 🔥 remove local system listFiles exposure

* 📝 clarify local system shell result wording

* 📝 refine local system shell manifest copy

* 📝 simplify local system shell prompt semantics

* 🐛 fix command wait-window result wording

* 📝 limit transient device retry guidance

* ✨ show command output duration

* 🏷️ narrow command duration result type

* 🐛 propagate operation id for device tool calls

* 🐛 update project skill discovery hint

* 📝 clarify project skill file access

* 📝 align project skill discovery comment
2026-06-13 16:34:10 +08:00
Coooolfan a28fd30719 ✨ feat: suppport sandbox provider (#15184)
* ✨ feat(cloud-sandbox): add Onlyboxes provider support for self-hosted sandbox (#15136)

- Add `SANDBOX_PROVIDER` env var (market | onlyboxes) to select sandbox backend
- Add Onlyboxes-specific env vars: `ONLYBOXES_BASE_URL`, `ONLYBOXES_API_TOKEN`, `ONLYBOXES_LEASE_TTL_SEC`
- Create `SandboxService` abstraction layer with `MarketSandboxService` and `OnlyboxesSandboxService` implementations
- Add `createSandboxService` factory that routes to configured provider
- Migrate `execInSandbox` and `exportFile` t

* ✨ feat(sandbox): improve Onlyboxes export flow

* 🐛 fix(sandbox): pass presigned upload headers to Onlyboxes

* ✅ test(sandbox): import tool runtime package

* 🐛 fix(sandbox): preserve Market export errors

* 🐛 fix(sandbox): allow empty docker env defaults

* 🔒 fix: redact sandbox auth params in logs

* 🐛 fix: address sandbox provider review comments

* 🔐 feat: use onlyboxes jit tokens

* 📝 docs: clarify cloud sandbox provider config

* 🐛 fix: align cloud sandbox timeout defaults

* 🐛 fix(sandbox): lower default Onlyboxes lease TTL to 15 minutes

* 🐛 fix(sandbox): cap Onlyboxes task wait time

* ♻️ refactor: split sandbox env config
2026-06-07 12:18:39 +08:00
Arvin Xu 72ea0f94f7 🐛 fix(cli): preserve content/state for connect local file/shell tools (#15442)
* 🐛 fix(cli): preserve content/state for connect local file/shell tools

Route file/shell tool calls in connect mode through LocalSystemExecutionRuntime
so the result carries formatted prompt `content` plus structured `state`, and
forward `state` over the gateway tool-call response — aligning the CLI with the
desktop gateway path (PR #15114).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): preserve getCommandOutput timeout when polling running commands

Routing getCommandOutput through the runtime dropped the per-call/gateway
timeout: the CLI mapping didn't forward it and LocalSystemExecutionRuntime's
denormalizeParams stripped it before ShellProcessManager.getOutput, so polling
fell back to the 30s default and could block past the gateway budget. Carry
timeout through the runtime param type, denormalize, and the CLI mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 01:28:11 +08:00
Arvin Xu 7d1086b096 🐛 fix(remote-device): preserve content/state across gateway tool calls (#15114)
The cloud→gateway→desktop path was JSON.stringify-ing the entire IPC result
into `content`, dropping `state` and leaking `{success: true, ...}` into the
LLM-facing prompt. Routes remote tool calls through `LocalSystemExecutionRuntime`
(same runtime the renderer uses) so `content` is the formatted prompt and
`state` flows through `DeviceProxy` → `RuntimeExecutors` into `pluginState`.

Also moves `LocalSystemExecutionRuntime` from `@lobechat/builtin-tool-local-system`
(renderer-coupled, React/antd peers) into `@lobechat/tool-runtime` so the
desktop main process can reuse it without pulling UI deps.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:01:25 +08:00
Rylan Cai 3e0a396968 ♻️ refactor: + run command observation timeout (#15195)
* ✨ add command observation timeout handling

* ✨ hide shell observation timeout from model

* 🐛 restore shell observation compatibility

* 🐛 clear device proxy caller timeout timer

* 🐛 separate shell observation and caller timeouts

* 📝 clarify shell output wait semantics

* 🐛 align shell manifest timeout semantics

* 🐛 restore incremental shell output semantics

* 🐛 restore get command output manifest wording

* 🐛 restore get command output prompt wording

* 🐛 remove running state from command output

* 🐛 restore local system server runtime passthrough

* 🐛 restore device proxy timeout passthrough

* ♻️ tighten shell observation implementation

* ♻️ defer completed shell cleanup policy

* ♻️ simplify shell observation wait

* ♻️ read shell exit code from child process

* ♻️ tighten shell output exit code handling

* ♻️ clarify shell observation wait race

* 🐛 add device gateway HTTP call timeout

* 📝 clarify shell command session prompts

* ✨ use incremental shell session ids

* ✨ pass execution timeout through local system tool chain

* 🚑 fix local system timeout CI coverage

* ✅ fix desktop shell controller tests
2026-05-26 14:53:34 +08:00
Arvin Xu 03c79bfb62 🐛 fix: surface stderr in errorOutput fallback and add UNKNOWN_EXEC_ERROR prefix (#14964)
* fix: surface stderr in errorOutput fallback and add UNKNOWN_EXEC_ERROR prefix

When a shell command fails with a non-zero exit code (e.g. git commit
with nothing to commit), the runner puts the error message in stderr
but does not set the error field. This caused errorOutput() to fall
through to the hardcoded 'Tool execution failed' string, losing the
actual error.

Changes:
- errorOutput() now checks state.stderr and state.error before the
  final fallback, so real error messages from stderr are surfaced
- Final fallback changed from 'Tool execution failed' to
  '[UNKNOWN_EXEC_ERROR] Tool execution failed' for easier grepping
- Same prefix applied to toResult() in the executor for consistency

* fix: pass stderr/stdout into errorOutput state for runCommand failures

runCommand() called errorOutput() with a state that only contained
{ error, isBackground, success }, missing result.result.stderr.
Since normalizeResult() stores the shell stderr under result.result.stderr
(not result.error), the state.stderr fallback in errorOutput() was
never reached for non-zero exit commands like 'git commit' with
nothing to commit.
2026-05-19 15:16:28 +08:00
Arvin Xu 8c3b83f8b3 🐛 fix(local-system): tokenize mdfind keywords, scope glob to home, align tool prompts (#14358)
* 🐛 fix(local-system): tokenize mdfind keywords, scope glob to home, align tool prompts

- mdfind treats free-form keywords as a single literal substring; "LobeHub
  Financial Statement" never matches "Financial_Statement_LobeHub.pdf".
  Split on whitespace and AND each token (still substring-matched) so
  ordering doesn't matter.
- Unix/Windows glob fell back to process.cwd() — meaningless inside a
  packaged Electron app. Default to os.homedir() instead so unscoped
  patterns can actually find user files.
- systemRole/systemRole.desktop documented `query`/`onlyIn`/`path` for
  searchLocalFiles/grepContent/globLocalFiles, but the manifest exposes
  `keywords`/`scope`. The wrong names were silently dropped, so the LLM
  could never scope its searches. Aligned the prompts with manifest and
  noted the new keyword-tokenization semantics.

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

* 🐛 fix(local-system): preserve glob/grep error in tool message content + tidy file row UI

Two independent bugs that combined to break Glob/Grep tool messages and
then made search hits look ugly in the result list.

Empty `content` on glob failure
- LocalSystemExecutionRuntime.normalizeResult dropped `raw.error` when
  mapping `globLocalFiles`/`grepContent`, so a failure from the IPC layer
  (e.g. fast-glob throwing EACCES while traversing the wrong cwd) became
  `{ result: {...}, success: false }` with no error attached.
- ComputerRuntime.errorOutput then did
  `result.error?.message || JSON.stringify(result.error)`. With error
  undefined that yields the value `undefined` (not the string), which
  collapsed into `content: ""` downstream — the chat store still saved
  `pluginState` so users saw a tool message with state set but the
  Response panel completely blank.
- Propagate `raw.error` through normalizeResult and harden errorOutput
  with a "Tool execution failed" fallback so the LLM and the debug panel
  always get a real string.

Search results layout
- FileItem stacked filename and a redundant full path on a single
  baseline-aligned row, so the path column repeated the filename and
  felt visually off-balance.
- Switch to a two-line layout: filename on top, parent directory only
  (collapsed via displayRelativePath when available) underneath, both
  vertically centered against the file icon.
- Promote the "open containing folder" action from hover-only to a
  permanent right-side button so it's reachable in one click.
- Bump the SearchFiles scroll container so the taller rows still show a
  reasonable number of hits before scrolling, and add a Downloads-style
  fixture to the dev panel render gallery.

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

* 🐛 fix(local-system): harden executor toResult to never emit empty content and to keep state on failure

The earlier fix patched normalizeResult and ComputerRuntime.errorOutput,
but the central funnel where every executor return is shaped —
LocalSystemExecutor.toResult — still trusted the runtime output blindly:

- the success=false branch dropped `state` entirely, which meant any
  partial pluginState a runtime had built up was thrown away the moment
  it reported an error (renderers then re-rendered as if the call had
  produced nothing).
- both branches passed `output.content` through verbatim, so an
  upstream regression that forgot to populate content (the recent Glob
  EACCES path) would still surface as a blank Response panel.

Make toResult the strict gate it claims to be: derive a non-empty
content from `output.content -> output.error.message -> "Tool execution
failed"`, and always propagate `state` regardless of `success`.

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

* 🔒 chore(devtools): sanitize searchLocalFiles fixture to use synthetic data

Replace real-looking filenames, paths and corporate identifiers in the
RenderGallery fixture with neutral sample-user / sample-quarterly-report
placeholders. The fixture is checked into the repo and shipped to every
contributor's dev panel — it shouldn't carry data that resembles a
specific person's Downloads/iMessage/WeChat layout.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:19:31 +08:00
Arvin Xu dc3c48e469 🐛 fix(local-system): forward all search params and guard empty mdfind (#14355)
* 🐛 fix(local-system): forward all search params and guard empty mdfind

- Pass through all resolved searchFiles params (keywords, fileTypes,
  date range, scope, etc.) instead of dropping everything except
  `directory`, which previously caused the executor to call mdfind
  with no query.
- Surface missing fields (`keywords`, `fileTypes`, `contentContains`,
  date range, sort, etc.) on `SearchFilesParams` so the cross-runtime
  type matches the actual contract.
- Short-circuit Spotlight search when there is no query expression so
  mdfind doesn't print its usage text and get parsed as phantom file
  hits, and drop unstattable rows instead of fabricating 0-byte
  placeholders.

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

* 🐛 fix(skills): guard empty command and forward description in desktop execScript

Desktop skills' execScript dropped `description` before IPC, so when an LLM tool call arrived without `command` (aborted stream, empty args, etc.) the runner crashed on `command.slice(0, 50)` and surfaced as "Failed to execute command: ...".

- runner.ts: return a proper error result when `command` is missing instead of throwing
- lobe-skills.desktop.ts: forward `options.description` to localFileService.runCommand for better logs and as a fallback when command is absent

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

* 💄 style(local-system): show empty state when file search returns no results

Previously the SearchFiles result panel rendered an empty Flexbox when there were 0 hits, leaving the area visually blank below "Number of searches: 0". Reuse the same Block + Empty pattern as web-browsing search and the existing `search.emptyResult` i18n key.

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

* 🐛 fix(local-file-shell): expand leading ~ in file operation paths

Node fs APIs don't expand `~` like a shell would, so paths supplied by
the LLM or pasted by users were failing with ENOENT. Apply expandTilde
across read/write/edit/move/rename/list/glob/grep/search and the desktop
search controller.

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

* 💄 style(local-system): show empty state when listed directory has no files

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:22:14 +08:00
Arvin Xu be99aaebd0 ♻️ refactor: unify tool content formatting with ComputerRuntime and shared UI (#13470)
* ♻️ refactor: unify tool content formatting with ComputerRuntime and shared UI components

Introduce `@lobechat/tool-runtime` with `ComputerRuntime` abstract class to ensure consistent
content formatting (via `formatCommandResult`, `formatFileContent`, etc.) across local-system,
cloud-sandbox, and skills packages. Create `@lobechat/shared-tool-ui` to share Render and
Inspector components, eliminating duplicated UI code across tool packages.

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

* 🐛 fix: address review issues — state mapping for renders and IPC param denormalization

- Add legacy state field mappings in local-system executor (listResults, fileContent,
  searchResults) for backward compatibility with existing render components
- Add denormalizeParams in LocalSystemExecutionRuntime to map ComputerRuntime params
  back to IPC-expected field names (file_path, items, shell_id, etc.)
- Fix i18n type casting for dynamic translation keys in shared-tool-ui inspectors

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

* ♻️ refactor: inject render capabilities via context, unify state shape for cross-package render reuse

- Add ToolRenderContext with injectable capabilities (openFile, openFolder,
  isLoading, displayRelativePath) to shared-tool-ui
- Update local-system render components (ReadLocalFile, ListFiles, SearchFiles,
  MoveLocalFiles, FileItem) to use context instead of direct Electron imports
- Enrich ReadFileState with render-compatible fields (filename, fileType,
  charCount, loc, totalCharCount)
- Cloud-sandbox now fully reuses local-system renders — renders degrade
  gracefully when capabilities are not provided (no open file buttons in sandbox)
- Remove executor-level state mapping hacks

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

* 🐛 fix: fix sandbox render bugs — SearchFiles, GrepContent, MoveFiles, GlobFiles

- SearchFiles: ensure results is always an array (not object passthrough)
- GrepContent: update formatGrepResults to support object matches
  `{path, content, lineNumber}` alongside string matches
- MoveFiles: render now handles both IPC format (items/oldPath/newPath) and
  ComputerRuntime format (operations/source/destination)
- GlobFiles: fallback totalCount to files.length when API returns 0

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

* 🐛 fix: unify SearchLocalFiles inspector with shared factory

SearchLocalFiles inspector now supports all keyword field variants
(keyword, keywords, query) and reads from unified state (results/totalCount).

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

* 🐛 fix: handle missing path in grep matches to avoid undefined display

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

* 🐛 fix: improve render field compatibility for sandbox

- EditLocalFile render: support both file_path (IPC) and path (sandbox) args
- SearchFiles render: support keyword/keywords/query arg variants
- FileItem: derive name from path when not provided

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

* 🐛 fix: add missing cloud-sandbox i18n key for noResults

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:42:45 +08:00