Feat/chat ask execute modes#18
Merged
Merged
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
Screenshots / GIFs
How to test
Checklist
devnpm run typecheckpassesnpm run buildpassesCHANGELOG.mdunder[Unreleased]if user-visibleROADMAP.mdif a planned item is now done