Skip to content

Feat/chat ask execute modes#18

Merged
tmlxrd merged 99 commits into
devfrom
feat/chat-ask-execute-modes
Jun 5, 2026
Merged

Feat/chat ask execute modes#18
tmlxrd merged 99 commits into
devfrom
feat/chat-ask-execute-modes

Conversation

@tmlxrd

@tmlxrd tmlxrd commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Changes

Screenshots / GIFs

How to test

Checklist

  • PR targets dev
  • npm run typecheck passes
  • npm run build passes
  • Updated CHANGELOG.md under [Unreleased] if user-visible
  • Updated ROADMAP.md if a planned item is now done
  • No new heavyweight dependencies (or motivated in the description)

tmlxrd and others added 30 commits June 1, 2026 14:59
highlightCodeBlocks() called hljs.highlightElement() unconditionally, so
fences without a language tag fell back to highlight.js auto-detection.
That mis-classified prose as Ruby — an apostrophe (e.g. "пам'ятає") opens
a string literal and the github-dark theme dims the rest of the block,
making plain text hard to read.

Now highlight only when the fence declares a language hljs recognizes;
bare/unknown/text fences render as plain monospace (no auto-detect).
Explicit-language blocks still highlight normally, so Cyrillic in code
comments is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Config-driven modes in services/chatModes.ts (ask, execute + disabled
  research/image/safe_run placeholders); single source of truth, extensible.
- Persist mode per message (messages.mode) and per queued row
  (queued_messages.mode); idempotent ensureColumn migrations, plain TEXT.
- Thread mode frontend -> WS 'send' -> chatRunner -> queue flush; missing/
  unknown -> 'execute' so legacy chats and older clients keep working.
- Ask prepends a lightweight 'answer, don't execute' directive to the gateway
  message; Execute is byte-for-byte unchanged. Seams left for routing Ask to a
  no-tools OpenClaw profile or OpenRouter later (see chatModes.ts + README).
- Minimal composer pill + dropdown selector, choice persisted in localStorage.
Ask no longer relies only on a prompt instruction. When a tools-restricted
agent exists on the gateway (ICLAW_ASK_AGENT, default 'ask'), each Ask turn runs
on a throwaway session bound to it, so OpenClaw enforces its tool policy and the
model physically cannot use shell/file/browser tools. The fresh session is
seeded with a compact snapshot of the recent thread so Ask keeps context; the
reply lands in the same chat thread.

- config: loadAskAgentId() (ICLAW_ASK_AGENT; empty/off disables hard Ask)
- openclawWs: cached agentExists() via agents.list
- chatRunner: resolveTurnTarget() routes ask->ephemeral restricted session
  (seeded context) or falls back to the soft prompt-only preamble; Execute
  unchanged. activeRunSessionKeys lets abort target the ephemeral ask session.
- docs: README hard-vs-soft Ask + config example + v1 limits; .env.example.

v1 limitation: Execute's native session memory doesn't include Ask turns
(Ask->Execute bridge via chat.inject is a planned follow-up).
After a hard-Ask turn (which runs on a throwaway tools-restricted session), the
chat's main Execute session has no native memory of it. Append a compact [Ask]
Q&A note to the main session via chat.inject — no model run, zero cost — so a
later Execute turn sees the exchange. Best-effort; stored content keeps secret
placeholders so nothing sensitive is injected.

- openclawWs.injectMessage({sessionKey,message,label})
- chatRunner: buildAskBridgeNote + inject after successful hard-Ask turn
- README: context now bridged both ways
Previously Ask silently fell back to a prompt-only 'please don't use tools'
turn on the full-tools session when the ask agent wasn't configured — so the
model could (and did) still create files. That fail-open is removed.

Now: a lightweight (Ask) turn runs only on the tools-restricted ask agent. If
that agent isn't configured/present (or ICLAW_ASK_AGENT is empty), chatRunner
refuses the turn with a system note instead of running with tools. No
prompt-only path remains.

- chatRunner: fail-closed gate before turn start; resolveTurnTarget no longer
  has a soft branch.
- chatModes: drop ASK_PREAMBLE + applyModeToGatewayMessage (dead); update notes.
- README/.env.example: document fail-closed behavior (no prompt fallback).
- New openRouter.ts: streaming/non-streaming chat completions + audio
  transcription (transcribeAudio) via /chat/completions. No extra deps —
  Node 25 global fetch.
- New kv.ts: tiny iclaw_kv accessor (key/value over existing DB table)
  so config can read settings without pulling store.ts (cycle-free).
- config.ts: loadOpenRouterConfig reads API key from KV (set in Settings,
  not .env); adds setOpenRouterApiKey / clearOpenRouterApiKey / maskOpenRouterApiKey.
- chatTitle.ts: prefer cheap OpenRouter call for titles; fall back to
  OpenClaw session when key absent or call fails.
- chatModes.ts: Ask gated on openRouterEnabled() — hidden from composer
  without key, posted 'ask' coerced to 'execute'.
- chatRunner.ts: Ask branch replaced — direct OpenRouter stream (gemini-
  2.0-flash default), AbortController for Stop, chat.inject bridge into
  main OpenClaw session preserved. Removed ask-agent machinery (ICLAW_ASK_AGENT,
  resolveTurnTarget, ASK_PREAMBLE). No prompt-only fallback.
- index.ts route: POST /api/stt endpoint (express.raw, 25 MB limit) →
  transcribeAudio → {text}; sttEnabled local for views.
- chats.ts: pass sttEnabled to chat view.
- .env.example / README: key is now set in Settings UI, not env.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add mic button next to the send button (rendered only when
OPENROUTER_API_KEY is set via Settings).

- composer.ejs: mic button with idle/recording/busy SVG states; gated on
  sttEnabled EJS local.
- iclaw.js: mic JS — MediaRecorder recording, pickMicMime(), transcribeBlob()
  POST to /api/stt, insertTranscript() into composer textarea on success.
  Stop mic on second click; spinner during transcription; alert on error.
- style.css: .composer-mic — mirrors .composer-attach tokens (32px circle,
  --muted, color-mix hover). Recording state: --danger color + 12% tint bg
  + pulse animation. Busy state: spinner replaces mic icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Settings is now sectioned by URL (/settings/voice-ask,
/settings/remote-access) with pill-tab navigation. /settings redirects
to the first tab.

Voice & Ask page:
- Minimal CTA: one-liner description + monospace key input + Connect
  button + openrouter.ai/keys link. No perks list (removed as noise).
- Connected state: green dot, masked key (sk-or-v••••••••xxxx),
  Disconnect button, live spend readout ($0.42 spent / $9.58 remaining
  + thin progress bar loaded async from /api/openrouter/usage).
- Endpoints: POST /api/openrouter/key (save+validate), DELETE
  /api/openrouter/key (clear), GET /api/openrouter/usage (fetchUsage).
- Key validated for sk-or- prefix; 400 on bad format.
- Page reloads on connect/disconnect so composer picks up new key state.

settings.ts: sidebarLocals() helper; per-tab routes with only the
locals each tab needs. RA script/modal/dialog gated in EJS so they
don't load on voice-ask tab.

sidebar.ejs: settings gear links directly to /settings/voice-ask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extend ChatMode type to include 'work'
- Add Work Mode entry in CHAT_MODES catalog (requires OpenRouter key)
- Wire chatRunner to route 'work' turns to iclaw-runtime service
- Add workRuntime.ts HTTP client (port 7430, SSE events)
- Add packages/iclaw-runtime/ — NanoClaw fork stripped to essentials:
  - No OneCLI dependency (removed)
  - No Telegram/WhatsApp/Slack/Discord channels
  - OpenRouter via ANTHROPIC_BASE_URL env passthrough
  - New iclaw-http channel: HTTP/SSE API for iClaw browser UI
  - Approval flow simplified for browser-first approval
  - Mount security module intact (path traversal prevention)
- Convert repo to npm workspaces monorepo

Attribution: packages/iclaw-runtime/ forked from NanoClaw (MIT)
https://github.com/nanocoai/nanoclaw

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spawns packages/iclaw-runtime/src/index.ts via tsx when iClaw starts,
shuts it down on SIGINT/SIGTERM. Auto-restarts on crash (max 5 times
per minute). No-op when iclaw-runtime is not installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Voice & Ask section now shows a bullet list of what the key unlocks:
voice messages, Ask mode, Work mode (with description), auto-named chats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gemini-2.0-flash not available on this account; gemini-2.5-flash is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Gemini 2.5 Flash has thinking enabled by default on OpenRouter which
causes very long waits. Explicitly disable it for Ask mode and titles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MODE_STORAGE_KEY is now iclaw:composer-mode:<chatId> so switching
between chats doesn't reset each other's mode selection.
Draft chats migrate their mode to the per-chat key on id adoption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- iclaw-session: seed agent group with model + write "user" destination
  in inbound.db so agent can use <message to="user">
- iclaw-http: fix session wiring (sessionId as platformId), isMention:true
- container-runner: pass ANTHROPIC_API_KEY for OpenRouter auth
- openrouter-proxy: strip unsupported beta headers (claude-code-20250219,
  advisor-tool-2026-03-01) that cause 400 on OpenRouter Anthropic endpoint
- session-manager: write "user" destination row on writeSessionRouting
- config: default model claude-3-5-haiku-20241022 (available on OpenRouter)

Work Mode full flow now works:
  iClaw UI → chatRunner → workRuntime HTTP → iclaw-runtime →
  Docker container → OpenRouter (via proxy) → SSE stream back to UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing loop

Remove Claude Code CLI dependency entirely. New agent:
- src/agent/loop.ts    — OpenRouter tool-calling cycle (any model)
- src/agent/tools.ts  — list_files, read_file, search_files, write_file, run_command
- src/agent/security.ts — path validation, blocked patterns (.env, .ssh, *.pem)
- src/sessions.ts     — in-memory session manager (no SQLite)
- src/index.ts        — minimal HTTP server (200 lines, replaces NanoClaw pipeline)

Works with any OpenRouter model that supports tool calling.
No Docker for Work Mode, no NanoClaw routing, no delivery pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Reuse session across turns so conversation history is preserved
- Pass HOME as default allowedFolder (TODO: use project settings)
- Ignore 'aborted' SSE errors — they mean the stream closed normally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Folder icon button appears in composer when Work Mode is selected
- Click opens modal: add paths, remove with ×, stored in localStorage
- workFolders passed via WS payload → chatRunner → createWorkSession
- Session reuses folders from first turn (per chat)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /api/pick-folder opens osascript dialog on macOS (zenity/kdialog on Linux).
Browse button in folders modal — click selects folder natively, no typing required.
Manual path input kept as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ect chats)

Storage key: iclaw:work-folders:project:<id> or iclaw:work-folders:no-project.
All chats in the same project share the same folder set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matches modern chat UI style (Claude.ai-like):
- Textarea full width at top with 'Ask anything' placeholder
- Toolbar row at bottom: [+ mode folders] ... [mic send]
- Mode selector as flat text label in toolbar (no pill border)
- All toolbar buttons share .composer-toolbar-btn base style

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Secure Mode:
- ChatMode extended: 'ask' | 'execute' | 'work' | 'secure'
- chatModes.ts: 'secure' mode enabled (requires OpenRouter)
- secure-runner.ts: runs agent loop inside Docker container
  (--network none, --memory 512m, disposable temp workspace)
- sessions.ts: routes secure:true sessions to secure-runner
- chatRunner.ts: mode==='secure' goes through runWorkModeTurn(secure:true)

Composer placeholders:
- Ask: "Ask safely…"
- Work: "Work inside selected folders…"
- Secure: "Run risky tasks in isolation…"
- Execute: "Use full iClaw power…"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tmlxrd and others added 28 commits June 3, 2026 10:44
…Claw

Dev-mode token badge for Full Power (OpenClaw) turns
- Per-turn usage lives on assistant rows of the gateway chat.history
  projection. runTurn already fetches that slice for authoritative text;
  extractTurnUsage() sums usage across the turn's assistant segments and
  threads {tokens, cached} into the same messages.append columns Work/Safe
  use, so the existing badge renders for execute turns too.
- Robust field extraction (camelCase / snake_case / Anthropic-style),
  defensive against stubs that omit usage. +unit tests.

Single 80/20 sandbox image for Work + Safe work
- secure-sandbox.Dockerfile is now the one shared image (added python3,
  wget, nano; no browser/compilers/heavy runtimes — long tail self-installs
  into /workspace/.tools). work-container resolveWorkImage() prefers it;
  node:22 is only an emergency fallback (logged).
- Rename mode "Sandbox" -> "Safe work & Internet research".

Delete dead NanoClaw inheritance (~164 files)
- Only 9 runtime files are reachable from index.ts. Removed all of
  cli/ channels/ db/ modules/ providers/, the dead top-level .ts,
  container/Dockerfile (+ agent-runner/, skills/, build.sh, entrypoint.sh),
  and the tracked groups/ fixtures. Reviewed first: nothing of value lost
  (live agent/security.ts deny-list exceeds the dead mount-security;
  command-gate/circuit-breaker were tied to the dead architecture).
- Trimmed runtime deps to just `openai`. Aligned README/AGENTS/runtime
  header docs to the host-loop + per-turn-sandbox model.

Verified: runtime + host typecheck clean, runtime builds, 390/390 host tests pass.

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

When analyze_link runs in summary mode it reads a long transcript but hands
the main model only the gist — and avoids re-sending the full text every
round. Surface that win as a persistent chat note.

- tools.ts: analyzeLink computes the saving (summary vs full transcript chars,
  a transparent token proxy) and fires an optional onNote callback. Gated so
  trivial clips don't post noise (>=2k chars, >=25% saved). New SavingsNote type
  + ToolContext.onNote.
- agent/loop.ts + secure-runner.ts: buffer notes from the tool call and flush
  them as a new `note` AgentEvent right after the matching tool_result (covers
  Work, Safe work and Incognito).
- workRuntime.ts: WorkEvent gains `note`. chatRunner appends it as a system
  row (finish_reason 'savings'), rendered by the existing system-note path —
  no frontend change.

Verified: runtime + host typecheck clean, 390/390 host tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Non-technical users couldn't parse the char-count version. Drop the
numbers/jargon: just the % saved and a warm thank-you.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iClaw saved you N% in cost while reading that link. Thanks for being with us 💚

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Work / Safe work / Incognito run on iclaw-runtime (OpenRouter), not the
gateway — but a new chat was hard-blocked by the "Can't connect to OpenClaw"
screen whenever the gateway was unreachable, and sendMessage provisioned a
gateway session for every mode.

- chatRunner: move ensureSession() out of the shared prologue into the Execute
  branch only, so runtime-mode turns never touch the gateway.
- index route/view: only show the blocking gateway error when OpenRouter is
  also unconfigured (openRouterReady). With a key, the picker/composer renders
  and defaults to Work; the header badge still shows OpenClaw as off.

Verified: typecheck clean, 390/390 tests pass.

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

Two issues when OpenClaw is off but an OpenRouter key is set:

1. Couldn't pick a project on a new chat. The composer wrap was gated behind
   `!agentsError`, so with the gateway down it wasn't rendered — and the
   project-pick click handler bails when the composer is absent. Render it when
   `openRouterReady` too.

2. Full Power (Execute) stayed selectable even though it needs the gateway.
   Now: the menu option is muted ("· needs OpenClaw") while OpenClaw isn't
   connected, and if Full Power is the selected mode the input is covered with
   an overlay ("OpenClaw is off. Turn it on, or switch mode.") and send is
   blocked — same treatment as the drag-drop overlay. Driven by the live gateway
   badge status, so it clears the moment OpenClaw connects.

Verified: typecheck clean, 390/390 tests pass, EJS templates compile.

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

The previous gating keyed off #gateway-badge, which only exists on the new-chat
header — so on chat pages isExecuteAvailable() always returned true and nothing
was disabled (the overlay sat at opacity 0 in the DOM, input stayed active, and
a Full Power send hit the gateway and errored).

- Server now stamps data-gateway-ok on the composer form on every surface
  (index: gatewayStatus==='ok'; chat: !agentsError).
- JS keeps a live `gatewayOk` flag seeded from the badge or that data attr, and
  updates it in applyGatewayStatus before the no-badge early return, so the
  Full Power mute + input overlay + send block work on chat pages too and clear
  live when OpenClaw connects.

Verified: JS/EJS/TS all parse, 390/390 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exec-disabled overlay covered the whole composer field — including the
toolbar with the mode switcher — so the user couldn't actually switch mode.
Replace the textarea with an inline message instead and leave the toolbar
(switcher, attach, send) in place; send stays disabled, the switcher is live.

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

Tool results accumulating in history and being resent every agentic round are
the dominant token sink (≈99% of history growth, quadratic in round count).
Several mitigations, all env-tunable:

- Prompt caching: mark the static prefix (system + tool schemas) with an
  OpenRouter/Anthropic `cache_control: ephemeral` breakpoint so it isn't re-billed
  every round. DeepSeek/Gemini auto-cache (breakpoint harmless), Anthropic needs
  it. Kill switch ICLAW_PROMPT_CACHE=off. (withPromptCaching)
- Mid-turn compaction threshold 32k -> 16k chars: work turns plateau ~15-20k, so
  32k almost never fired. Now also protects the FIRST few tool outputs as well as
  the last few (Hermes-style protect_first_n/last_n).
- Tool-loop guardrail: same tool+args repeated past ICLAW_TOOL_REPEAT_LIMIT (3) is
  short-circuited with a nudge instead of burning another round. (makeToolGuard)
- run_command output compression (rtk-inspired, generic + lossless-for-signal):
  strip ANSI escapes, collapse \r progress bars to final frame, dedupe >=3
  identical lines into (xN). Runs before the 8k clamp so the cap keeps signal,
  not noise. (compressCommandOutput)
- search_files: cap matches per file (grep -m 30) + clamp total output.

Applied symmetrically to the Work/Incognito loop and the Secure sandbox loop.
Build + all 390 host tests pass.

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

Attachments dropped in Work/Secure/Incognito chats now reach the runtime
agent (previously persisted + shown in the UI but never sent, so the model
said "I don't see a file"):
- chatRunner forwards persisted uploads (absolute host paths) through
  runWorkModeTurn → sendWorkMessage → POST /sessions/:id/messages.
- Runtime stages them per turn: images become vision blocks (model sees
  them); other files are copied into the Secure workspace or made readable
  in Work/Incognito, with a notice telling the model the file + its path.

search_files overhauled for speed, truth, and the "locate a file" case:
- Always uses bundled ripgrep (@vscode/ripgrep) — single path, no grep
  fallback, no per-host detection, no .gitignore parsing. Skips binary
  files / huge files / symlinks natively; we feed our own exclude-dir list
  as ignore globs so it works with or without git.
- Timeouts are no longer masked as "No matches found" (the false negative
  that made the agent report a present file as missing).
- Omitting `path` searches every allowed folder in one rg call (no per-
  folder AI iteration); a security error on an out-of-scope path hints to
  omit `path` instead.
- New `name` mode locates files by filename, returning paths only — the
  right primitive for "where is file X" (content grep matched logs/traces
  with huge single lines). Content matches are clamped per line
  (--max-columns) so minified/JSON lines can't blow the token budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y) + search

When a tool hands the model less than the full content, post a friendly,
plain-language chat note BELOW the reply:
- read_file / read_summary / run_command: a real measured percentage, computed
  for free from numbers already in hand (full vs delivered) — never a
  fabricated baseline. Gated (≥4000 chars full, ≥30% saved) so tiny
  truncations don't spam.
- search: ripgrep clamps long match lines but never reports how many bytes it
  dropped, so we post a quantity-free note (no invented %) when ≥2 oversized
  lines were trimmed.

SavingsNote generalized (savedPct/fullChars/deliveredChars optional). Wording
is jargon-free for non-technical users — "iClaw обробив це на N% економніше,
ніж звичайні помічники" — a category claim anchored to the naive approach
(load the whole file), not a per-competitor benchmark we don't measure. Notes
are buffered during the stream and flushed after the assistant row so they
render under the message, not above it.

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

Onboarding screen (/welcome, gated by onboarding.done):
- "Connect OpenRouter" / "iClaw credits (coming soon)" choice, plain-language
  steps, live key validation (rejects dead/zero-credit keys before chat)
- background env prep: Docker detect + states (missing/stopped/starting/
  pulling/ready) with auto-start on macOS/Windows + image pre-pull; status line
  only shows when there's something to say
- auto-skip when an OpenRouter key already exists

First chat (when no chats yet):
- conversational welcome message highlighting real differentiators (sandbox,
  allowed-folders, token efficiency), suggestion chips that send on click
- "Warming up…" label on the first (cold-start) turn
- skips the project picker on the very first chat

Runtime:
- identity line in the agent/secure system prompts so the model says it's iClaw
  (powered by <model>) instead of hallucinating Claude/GPT
- default model -> deepseek/deepseek-v4-flash (override via ICLAW_MODEL)
- restart the runtime when the OpenRouter key changes, so Work/Safe/Incognito
  work right after onboarding without an app restart
- Safe work: append project context to the sandbox prompt instead of replacing
  it (was dropping identity + sandbox rules for project chats)

UX: redirect to home (instead of a 404 page) for a missing chat/project

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the "OpenClaw: connected/unreachable/off" chip and all its dead
code. The composer include now carries gatewayOk so Full Power gating
and the sidebar "Start OpenClaw" banner keep working without the badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Work container same-path-mounted folders at their host absolute
path, which is invalid on Windows (a Linux container can't have a
`C:\Users\foo` mountpoint). Translate host drive paths to their Docker
Desktop container form (`C:\Users\foo` -> `/c/Users/foo`) for the mount
target, working dir, and cwd, and rewrite any drive-path token the model
emits in a run_command. Mount source stays the native host path (the
Docker CLI handles the drive prefix in -v). Identity on macOS/Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Safe work IS the Docker sandbox, so it's now disabled (greyed in the
mode menu + a blocking overlay) when Docker isn't running. Work and
Incognito keep working — their file tools run on the host — and only
show a soft "shell needs Docker" hint, since just run_command is lost.

Adds a docker service (cached status probe + background start/install,
single-flight), localhost-only /api/docker status|start|install routes,
and scripts/install-docker.sh (brew cask / get.docker.com). The composer
polls status and the Install/Start button installs then auto-starts the
daemon, showing progress. requiresDocker flag drives which modes block
vs hint; the menu suffix reads "needs Docker" or "needs OpenClaw".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the same-path mount scheme (and its Windows drive-letter
translation) with normalized /work/<n> container paths that work
identically on macOS, Linux and Windows. Each allowed folder maps to
/work/<index> as :ro or :rw; the model still speaks host paths, so the
run_command host cwd and any host folder roots in the command are
translated to their container paths, and a cwd outside every mount is
refused — the command can never touch the rest of the computer.

After each command, scan the writable mounts (before/after find diff,
excluding .git/node_modules) and append a "File changes" report listing
created/modified/deleted files by their host paths, confirming all
changes stayed inside the allowed folders.

This is Work Mode's real-folder bind-mount model; Safe Mode's
copy-into-sandbox model is separate (next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Realize Safe Mode's "work on a copy" model. Folders the user picks in
Safe Mode are now COPIED into the isolated sandbox workspace on the
first turn (originals never touched), instead of being ignored — the
distinct counterpart to Work Mode's live bind mounts.

Adds secure-ingest.ts: ingestSources() brings a source into the
workspace — folder (recursive copy, skips node_modules/.git, refuses
secret roots), repo (shallow git clone), zip (unzip), url (download) —
writes a .iclaw-ingest.json log and a user-facing summary. copyFolders
is threaded chatRunner → workRuntime → runtime route → session; the
ingest summary is prepended to the turn so the model knows what landed
in /workspace and tells the user their originals are unchanged.

Module supports repo/zip/url already; their composer input UI and an
explicit Destroy-sandbox button are follow-ups (backend destroy exists
via session teardown + TTL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add HOST_INSTALL_POLICY to the Work and Safe system prompts: the agent
must never install Python/Node/Git/Docker/Homebrew/system packages on
the user's machine or edit their PATH. Since run_command runs in an
isolated sandbox, in-sandbox installs are fine but don't reach the host;
when a task truly needs something on the user's computer, the agent
explains the impact and gives the official command/link to run.

Prompt-only by design: run_command can't touch the host (kernel
isolation), so a run_command guard would wrongly block legitimate
in-sandbox installs. Full Power runs via OpenClaw and is out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface an explicit "Destroy" control in the secure workspace bar that
tears down the chat's sandbox — stops the container and deletes the
copied workspace and everything in it (originals untouched). The next
message starts a fresh sandbox, re-copying any selected folders.

Adds destroyWorkSession(chatId) in chatRunner (stops the runtime session
+ clears the map) and POST /chats/:id/destroy-workspace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tell the agent explicitly that Safe Mode is the "work on a copy" mode:
selected folders are already copied into /workspace and dropped files
land there too, and to examine an untrusted repo/archive/URL it should
clone/unzip/curl it into /workspace itself — never reaching for host
paths. Makes the agent actually use the isolation model it's given.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously copyFolders were ingested only at session creation, so a
folder the user added after the first message was silently dropped.
Forward the current folder selection on every secure turn and track
already-copied paths in session.ingestedFolders, so each folder is
copied into the sandbox exactly once and mid-chat additions are picked
up on the next turn — without recreating the session or losing sandbox
state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two user-initiated actions to the secure workspace bar:
- Export: copy the whole sandbox out to a host folder (default
  ~/Downloads/iclaw-sandbox-<ts>), excluding workspace internals. Never
  touches the user's existing files.
- Apply changes: for each folder that was copied INTO the sandbox, copy
  the new/changed files back to the ORIGINAL folder. Additive + overwrite
  only (never deletes), re-validates the destination (refuses secret
  roots), and reports what was applied.

secure-export.ts (exportWorkspace/applyChanges, content-aware diff via
the .iclaw-ingest.json mapping) + runtime /sessions/:id/export|apply +
host /chats/:id/export-sandbox|apply-sandbox + bar buttons. Tests in
test/unit/secureExport.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stop nagging the user about Docker up front. Docker is now started
lazily — the first moment a task actually needs a sandbox (run_command
/ analyze_link in Work, or a Safe turn) — so pure file-edit and
read-only turns never touch it. And if WE started it, a background
reaper stops it again after 15 min idle, but only when `docker ps` is
empty, so we never kill the user's own Docker containers.

- docker-lifecycle.ts: ensureDockerForTask() (single-flight lazy start,
  tracks weStartedDocker) + startDockerIdleReaper() (15 min idle +
  container-free + self-started → osascript quit). Linux is user-only.
- sessions.ts: run_command/analyze_link/Safe start Docker on demand
  instead of gating on dockerAvailable() up front.
- UI: removed the proactive "Shell commands need Docker" Work note;
  the Safe overlay now blocks only when Docker is missing (not merely
  stopped — that case auto-starts on send).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Onboarding used to start Docker (open -a Docker) to pre-warm Work/Safe.
But that start happened on the HOST while the auto-stop reaper's
"we started it" flag lives in the RUNTIME process — so an
onboarding-started daemon was never tracked and never auto-stopped, and
it also eagerly started Docker for users who may only use Full Power.

Onboarding is now informational: it probes Docker and pre-pulls the
sandbox image only if the daemon is ALREADY up (user-started). When
Docker is installed-but-stopped it just reports that; the runtime starts
it on demand the first time a task needs it (and auto-stops it when
idle), so every transparent start is tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-technical user doesn't know or care what Docker is, and the
"installed/stopped/missing" lines weren't actionable — Docker is started
on demand automatically now. Onboarding shows only a neutral
"Setting up your workspace…" while the sandbox image downloads (and
only when Docker is already running). The Docker requirement surfaces
in-context later — with an Install button — only if the user actually
picks Safe work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two hardening fixes borrowed from studying NanoClaw:

1. --user host UID:GID on the Work container (with HOME=/tmp). Work
   bind-mounts the user's REAL folders :rw, but the container ran as the
   image user (node/1000) or root, so files run_command created landed
   owned by root/1000 — the user couldn't edit them (real bug on Linux;
   macOS Docker Desktop hid it by remapping ownership). Now they're owned
   by the host user. Guard skips root, uid 1000, and Windows.

2. install-id.ts stamps every work+secure container with
   --label iclaw-install=<sha1(installRoot)>. The orphan reapers now
   filter by name prefix AND that label, so a second iClaw install on the
   same machine can't reap (kill) this install's sandboxes.

Live-verified: container runs as 501:20, created files owned by 501,
label present; node/git/npm all work. iClaw's secret deny-list was
already broader than NanoClaw's, so nothing borrowed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "iClaw started Docker" flag lived only in the runtime process's
memory, and the reaper ran in that same process. So when the runtime
restarted (crash-respawn, or restart-on-key-save) or iClaw exited, the
flag was lost and the reaper died — a Docker daemon iClaw had started
was never auto-stopped. This is why Docker kept running after we started
it.

Persist ownership to a marker file (~/.iclaw/.docker-owned): its
existence means iClaw started the daemon, its mtime is the last task
that used it. A restarted/fresh runtime inherits the marker and its
reaper stops the daemon when idle + container-free, then clears it. We
never create the marker for a daemon the user started (no false claim),
verified by smoke. The "no running containers" guard still prevents
killing anyone's Docker work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add stopDockerOnShutdown(), wired into the runtime's SIGTERM/SIGINT
handlers, so quitting iClaw doesn't leave behind a daemon we started.
Only fires when we own Docker (durable marker) AND no container is
running — so it never kills the user's Docker work, and a restart where
a warm sandbox is still up (e.g. key-save respawn mid-task) just exits
fast and leaves the marker for the next runtime. Hard-capped with a
race so a hung Docker can't block the exit.

Together with the durable marker, this closes the "Docker we started
keeps running" gap on both paths: restart (marker inherited) and full
quit (stopped on shutdown).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tmlxrd
tmlxrd merged commit 80a822a into dev Jun 5, 2026
2 checks passed
@tmlxrd
tmlxrd deleted the feat/chat-ask-execute-modes branch June 5, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant