Compare commits

...

118 Commits

Author SHA1 Message Date
bellman
eaa2e320d9 docs: add ROADMAP #833 — native Ollama provider support via OLLAMA_HOST 2026-06-05 12:27:08 +09:00
bellman
be8112f5f5 feat: add native Ollama provider support via OLLAMA_HOST env var
- OLLAMA_HOST takes priority over OPENAI_BASE_URL for local Ollama instances
- No API key required; placeholder token used for Authorization header
- Model names like 'qwen3:8b' bypass strict provider/model syntax validation
- detect_provider_kind() checks OLLAMA_HOST first in routing cascade
- ProviderClient dispatch uses from_ollama_env() when OLLAMA_HOST is set
- Updated USAGE.md and docs with OLLAMA_HOST as preferred env var
- Added OLLAMA_CONFIG constant and from_ollama_env() to openai_compat
- Added test_ollama_host_bypasses_model_validation unit test
- Supersedes PR #3213 (which had a duplicate if-let bug in mod.rs)
2026-06-05 12:12:56 +09:00
bellman
503d515f38 style: cargo fmt after merged PRs (#3164, #3209, #3214, #3216)
Fix formatting inconsistencies introduced by merged external PRs.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:52:37 +09:00
Bellman
114c2da9fc Merge pull request #3216 from TheArchitectit/worktree-session-resume-fixes
fix: session resume — scan all workspaces, skip current empty session
2026-06-05 10:38:19 +09:00
Bellman
b90f18f75a Merge pull request #3214 from TheArchitectit/worktree-api-timeout-retry-v2
feat: API timeout config, Retry-After header, configurable retry, and 400 transient retry
2026-06-05 10:33:35 +09:00
Bellman
5b15197117 Merge pull request #3209 from Sam0urr/harden-permission-enforcer
Harden permission enforcement against sandbox bypasses
2026-06-05 10:32:50 +09:00
bellman
61e8ad9a8e docs: add gajae-code to Ecosystem section
Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:27:06 +09:00
Bellman
ef0d0c30a4 Merge pull request #3164 from petterreinholdtsen/llama.cpp-errors
fix: recover from llama.cpp context overflow and reqwest SSE decode failures
2026-06-05 10:25:54 +09:00
bellman
7305e5509a fix: update skills help test assertions for --project flag (#95 CI fix)
Updated the agents_and_skills_usage_support_help_and_unexpected_args
test to match the new skills help text that includes [--project].

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:23:36 +09:00
bellman
2f0b5b36eb fix: wrap concurrent ENOENT as domain-specific session error (#112)
Session save_to_path now wraps ENOENT errors from rotate and atomic
write with a clear "possible concurrent modification" message instead
of surfacing raw OS errno. Helps operators debugging race conditions
when multiple claw invocations touch the same session file.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:19:43 +09:00
bellman
c848eeb768 fix: status JSON reports all workspace panes not just the first (#326)
SessionLifecycleSummary now collects all matching tmux panes into an
all_panes field and includes them in the JSON output. Previously the
status command returned on the first non-idle pane, losing all other
active panes in the same workspace/session.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:15:23 +09:00
bellman
d4aad7103e fix: add actionable auth hint to 401/403 API errors (#28)
401 and 403 errors now include a hint explaining which env vars to
check for each provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)
and suggesting claw doctor for credential verification.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:10:24 +09:00
bellman
b60cbebb3a feat: skills install --project flag for project-level scope (#95)
claw skills install --project <path> now installs to .claw/skills/
in the current project instead of the user-level registry. Skills
installed at project level are already discovered by the existing
registry system. Both text and JSON handlers updated.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:05:36 +09:00
bellman
7c4bcd92b6 fix: expand ${VAR} and ~/ in MCP config fields (#92)
MCP server config now expands ${VAR} environment variable references
and ~/ home directory prefix in command, args, and url fields. Previously
these values were passed verbatim to execve/URL-parse, causing silent
"No such file or directory" failures for standard config patterns.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:00:45 +09:00
bellman
f8822aabdb fix: config merge concatenates arrays instead of replacing (#106)
deep_merge_objects now concatenates arrays when both layers provide
the same key. Previously permissions.allow, hooks.PreToolUse, etc.
from earlier config layers (e.g. ~/.claw/settings.json) were silently
discarded when a later layer (e.g. project .claw/settings.json) set
the same key. Now arrays are merged additively across layers.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 09:57:22 +09:00
bellman
aca6584fd5 fix: normalize permission rule tool names to lowercase (#94)
PermissionRule::parse now normalizes tool_name to lowercase, matching
the runtime convention. Previously "Bash(rm:*)" would never match
because the runtime tool name is lowercase "bash". Same fix applied
to denied_tools list.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 09:53:26 +09:00
bellman
b8cbb1834f fix: /clear preserves session_id to prevent resume divergence (#114)
/resume mode /clear now preserves the original session_id instead of
generating a new one. This prevents the filename/meta-header divergence
where /session list reported an id that --resume couldn't find.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 09:47:37 +09:00
bellman
cb027adf65 fix: /session switch and /session fork return structured JSON in resume mode (#113)
/session switch and /session fork now return structured JSON with
kind:error, error_kind:unsupported_resumed_command, and actionable
hint instead of a raw error string that resume callers couldn't parse.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 09:42:16 +09:00
bellman
5bdffbe161 docs: mark ROADMAP #330 DONE (verified /cost and /stats work in resume)
Verified with a roundtrip test: creating a session with usage data
({input_tokens:100, output_tokens:50}), saving to JSONL, loading via
--resume, and running /cost and /stats both return correct values.
The UsageTracker::from_session reads message.usage which is properly
serialized/deserialized by ConversationMessage::to_json/from_json.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 09:32:23 +09:00
bellman
04f18862a8 fix: strip macOS /private symlink prefix from JSON cwd (#421)
Add friendly_cwd() helper that strips /private prefix on macOS so
status, doctor, and diff JSON output matches user-visible invocation
cwd instead of the canonicalized /private/tmp path.

Applied to status_context() and render_doctor_report().

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 09:08:09 +09:00
bellman
f0e10ff182 docs: mark ROADMAP #342 DONE (covered by #325)
342: /commands command-index alias is now covered by /help --output-format
json which returns structured commands array with 139 entries (name,
summary, resume_supported per command). Implemented in #325.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 08:56:25 +09:00
bellman
0cab03df75 fix: /model returns structured JSON in resume mode (#343)
/model was in the unsupported resume group but is a read-only command
that can safely return model configuration. Now returns default_model,
configured_model, resolved_model, and requested_model in JSON mode.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 08:54:11 +09:00
bellman
eb86f4d2c3 fix: reject --compact for non-prompt subcommands (#98)
--compact was silently ignored when used with non-prompt commands like
claw --compact status or claw --compact config. Now returns a typed
error with guidance on proper usage.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 07:25:49 +09:00
bellman
6c3d7be370 fix: /tasks returns structured JSON in resume mode (#341)
/tasks was marked resume_supported but rejected in resume mode.
Now returns a structured JSON response with kind:'tasks' and note
that background tasks are only available in the interactive REPL.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 07:15:36 +09:00
bellman
e3ffaefba1 fix: /config help returns structured section list (#344)
- /config help now returns available_sections array and loaded_keys
  count instead of treating 'help' as an unsupported section
- Updated test to exclude 'help' from unsupported sections test
- Added new test config_help_returns_structured_section_list_344

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 07:09:48 +09:00
bellman
cd18cf5385 fix: /config help returns structured section list (#344)
/config help now returns a list of available config sections in both
text and JSON mode instead of treating 'help' as an unsupported section.

Text mode shows available sections with descriptions.
JSON mode returns available_sections array with loaded_keys count.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 07:05:16 +09:00
bellman
2f3120e70a fix: add structured command list to top-level help JSON (#325)
Top-level 'claw --output-format json help' now includes a 'commands'
array with name, summary, and resume_supported for each registered
slash command, plus 'total_commands' count.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:57:51 +09:00
bellman
f1a16398fe fix: plugins enable/disable only reloads when state changes (#411)
plugins enable now checks if the plugin was already enabled before
setting reload_runtime:true. Same for disable. Returns 'already enabled'
or 'already disabled' in the message when no state change occurred.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:49:01 +09:00
bellman
8d50276366 docs: mark ROADMAP #339 DONE (/session delete resume-safe)
339: /session delete works in resume mode with --force flag. The
confirmation-required path returns a typed JSON error with hint.
delete-force path performs actual deletion with proper JSON response.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:40:48 +09:00
bellman
13992ade54 docs: mark ROADMAP #129 DONE (verified code flow)
129: MCP server startup does not block credential validation - credentials
are resolved via resolve_cli_auth_source() before build_runtime() which
is where MCP state discovery happens.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:33:20 +09:00
bellman
b04b1d6ac8 feat: detect git rebase/merge/cherry-pick/bisect states (#89)
Add GitOperation enum to detect mid-operation git states from the
branch header in git status --short --branch output.

- Rebase: 'rebasing ...' in branch header
- Merge: '[merge-in-progress]' tag
- Cherry-pick: 'cherry-pick-in-progress' tag
- Bisect: 'bisect-in-progress' tag

Operation state appears in:
- status text: 'rebase-in-progress, dirty · 3 files · ...'
- status JSON: 'git_operation' field (null when no operation)
- git_state headline includes operation prefix

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:31:33 +09:00
bellman
934bf2837a fix: validate --base-commit is a hex SHA (#122)
--base-commit now rejects non-hex strings and strings outside 7-64
character range. Matches the pattern used by --reasoning-effort.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:21:24 +09:00
bellman
b94c49c323 fix: use existing path in system-prompt test (#99 fix regression)
The --cwd validation added in #99 rejects non-existent paths, but the
test used /tmp/project which doesn't exist on CI Linux runners. Changed
to /tmp which exists everywhere.

Also marks ROADMAP #123 DONE (--allowedTools normalization verified).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:13:25 +09:00
bellman
5df485cba9 docs: mark ROADMAP #120,121 DONE
120: config parse errors now surfaced via load_error with hint
121: hooks already uses PreToolUse/PostToolUse/Claude Code format

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 06:05:36 +09:00
bellman
c8e973513c fix: redact MCP server sensitive fields in JSON (#90)
MCP server details JSON now redacts args (shows count only),
strips URL query params which may contain tokens, and shows
headers_helper as configured/not-configured boolean instead of
the raw command string. env_keys and header_keys still exposed
(key names only, not values).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:59:52 +09:00
bellman
8f9315bdc9 docs: mark ROADMAP #32,43 DONE with runtime evidence
32: OpenAI model passthrough verified (model: openai/gpt-4, source: flag)
43: Hook ingress opacity fixed (hook_validation structured in status JSON)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:51:18 +09:00
bellman
12a091afe6 docs: mark ROADMAP #111,115 DONE
111: /providers routes to doctor slash command
115: init generates acceptEdits not dontAsk

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:48:42 +09:00
bellman
9f0cf3b888 docs: mark ROADMAP #101,104 DONE
101: RUSTY_CLAUDE_PERMISSION_MODE normalize maps aliases to valid modes
104: export CLI path and slash command both return kind:export

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:46:04 +09:00
bellman
6e94c1a97f docs: mark ROADMAP #93,109 DONE
93: --resume /tmp returns typed invalid_resume_argument error
109: config validation emits structured ConfigDiagnostic + warnings[]

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:40:50 +09:00
bellman
38626cad20 docs: mark ROADMAP #85,88,125,126 DONE
85: skills discovery bounded by git root (nearest_git_root in prompt.rs)
88: instruction file discovery bounded by git root
125: git_state reports 'no_git_repo' when not in git repo
126: config section exposes 'merged' key-value pairs

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:36:41 +09:00
bellman
e84f7c8034 fix: report 'no_git_repo' instead of 'clean' when not in git (#125)
status/doctor JSON now reports git_state:'no_git_repo' when
project_root is None, instead of the misleading 'clean' which
implied a git repo was present with zero changes.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:34:56 +09:00
bellman
b8f066347b fix: structured bootstrap-plan phases JSON (#412)
bootstrap-plan --output-format json now returns phases as structured
objects with id, label, description, and order fields instead of
raw Rust enum variant name strings. Also exposes total_phases count.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:29:27 +09:00
bellman
5adc751053 docs: mark ROADMAP #97 DONE
97: empty --allowedTools is unrestricted (no restrictions = default mode)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:24:15 +09:00
bellman
adf5bd165e fix: validate --cwd and --date for system-prompt (#99)
--cwd now validates the path exists and is a directory before passing
it to the system prompt renderer. --date rejects values with newlines
or >20 chars to prevent prompt injection.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:18:31 +09:00
bellman
b220366176 docs: mark ROADMAP 81-83,87,91,102,117,128 DONE with direct evidence
81: project_root correctly reports per-CWD paths
82: sandbox correctly reports macOS degraded state (filesystem_active + unsupported)
83: system-prompt --date parameter works correctly
87: default permission_mode is workspace-write from config
91: permission mode aliases resolve to valid modes
102: config-time MCP check is by design for local preflight
117: -p flag parses single token correctly
128: validate_model_syntax rejects malformed models with hints

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:10:08 +09:00
bellman
b5d67ef249 docs: mark ROADMAP #105,118 DONE
105: status reads model from .claw.json config (model_source: config)
118: /stats and /tokens both route to stats handler

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:04:27 +09:00
bellman
9a40568e1c docs: mark ROADMAP #80,86,100 DONE
80: session lookup error now returns session_not_found with hint
86: .claw.json invalid JSON handled gracefully by config loader
100: binary_provenance exposes git_sha, build_date in status JSON

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:02:30 +09:00
bellman
41b3566468 fix: update resume help test for message field parity (#338)
Changed resumed_help_command_emits_structured_json to assert 'message'
field instead of 'text', matching the #338 fix for help JSON field
consistency.

Also marked ROADMAP #340 and #345 DONE with direct runtime evidence:
340: resume /session help now routes JSON to stdout (verified)
345: resume /config <section> now returns section-specific JSON (verified)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 05:01:05 +09:00
bellman
b86159cbb7 docs: mark ROADMAP #124,127 DONE with direct evidence
124: validate_model_syntax already rejects empty, spaces, and invalid formats
127: subcommand --json already routes to local JSON handlers

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:56:06 +09:00
bellman
5e5b9966c0 docs: mark 8 ROADMAP items DONE (78,84,103,107,108,110,116,119)
78: plugins CLI wired as CliAction::Plugins (verified runtime)
84: dump-manifests fixed 2026-06-03
103: agents discovery handles .md with YAML frontmatter (#442)
107: hook validation in doctor JSON (verified runtime)
108: CLI typos → command_not_found (verified runtime)
110: ConfigLoader discovers project/user/local configs
116: unknown keys now emit warnings not errors (#441)
119: bare_slash_command_guidance checks aliases (#772)

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:52:40 +09:00
bellman
33f771f3f5 docs: mark ROADMAP #77 DONE (classify_error_kind implemented)
Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:51:16 +09:00
bellman
3fbfcc40ca docs: mark ROADMAP 327,328,408,6 DONE with fix evidence
327: mcp help now includes .claw.json in sources list
328: agents help now includes ~/.codex/agents in sources list
408: status JSON now has is_clean boolean field
6: terminal is transport principle — implementation tracked by #810-#816

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:47:58 +09:00
bellman
c7c5c11d1e fix: correct help source lists, add is_clean to status JSON
#327: mcp help now includes .claw.json in documented sources list
#328: agents help now includes ~/.codex/agents in documented sources list
#408: added is_clean boolean to status JSON workspace for unambiguous
  dirty-state detection; changed_files documented as total non-clean count
#338: resume help field consistency (committed earlier)

Also marked 52 items with explicit done/verified evidence as DONE
in ROADMAP.md, including product principles (1-6) and items with
confirmed fix text (13-75, 96, 200).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:46:10 +09:00
bellman
c0447e2be1 docs: mark ROADMAP 338,410 DONE with fix evidence
338: resume help now uses 'message' field consistent with top-level help
410: mcp and skills list now expose 'count' field for polymorphic consumption

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:35:51 +09:00
bellman
1b22ed700a fix: standardize list command count fields and resume help field name
#410: Added  field to mcp list and skills list JSON for
polymorphic consumption parity with agents list. All three sibling
list commands now expose a canonical  integer field alongside
their command-specific details.

#338: Changed resume  JSON from  to  field for
consistency with top-level .

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:34:59 +09:00
bellman
76f9a134ae docs: mark 16 ROADMAP items as DONE with direct verification evidence
Items verified with direct runtime evidence:
- #7: plugins CLI wired as CliAction::Plugins
- #77: classify_error_kind exists with 69 references
- #90: error envelopes carry machine-readable hint field
- #119: bare_slash_command_guidance checks aliases
- #346: agents show returns agent_not_found error
- #348: plugins list returns structured plugins[] array
- #349: plugins show returns plugin_not_found error
- #350: plugins enable returns typed error (not hang)
- #351: plugins disable returns typed error on stdout
- #352: plugins update returns typed error on stdout
- #353: plugins uninstall returns typed error on stdout
- #354: memory list returns interactive_only error
- #355: session list returns structured JSON
- #358: cost --help returns command_not_found
- #380: tokens --help returns command_not_found
- #381: cache --help returns command_not_found

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:27:24 +09:00
bellman
9c11325e83 docs: mark additional pre-440 ROADMAP items as DONE
Items verified against current codebase: 322, 323, 329, 337, 422 and
10 early items with confirmed fix evidence.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:23:02 +09:00
bellman
4708ab1611 fix: add structured help JSON and provider BASE_URL validation
#683-#692: help topic JSON now includes usage, purpose, formats,
related, local_only, requires_credentials, and aliases extracted
from help prose. Export and doctor keep their custom structured
responses.

#466: new check_base_url_health() validates ANTHROPIC_BASE_URL,
OPENAI_BASE_URL, XAI_BASE_URL, and DASHSCOPE_BASE_URL for basic
HTTP(S) URL format. Non-http schemes and empty values produce a
warn-level diagnostic.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 04:14:08 +09:00
bellman
311e719e5d docs: mark ROADMAP 696-697 DONE
696: compact returns interactive_only error in non-interactive mode
697: plugins uninstall already returns typed not-found error

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:49:31 +09:00
bellman
b926a9d25f docs: mark ROADMAP 713-716,723,734-735,737,767,771,774-776,781 as DONE
14 items with verified fixes marked as DONE.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:45:14 +09:00
bellman
61d641d722 style: apply cargo fmt
Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:41:35 +09:00
bellman
2f8679bd15 fix: track duplicate global flags in status JSON
status --output-format json now exposes duplicate_flags array listing
any --model, --output-format, or --permission-mode flags specified
more than once. Uses a module-level static for cross-function access.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:36:16 +09:00
bellman
9ef21e23f3 fix: expose merged key-value pairs in config JSON
render_config_json now includes a 'merged' object with actual
key-value pairs from the resolved runtime configuration, not just
the count.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:12:19 +09:00
bellman
6ac0386094 docs: close ROADMAP 335 evidence
335: session_details already includes created_at_ms

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:50:54 +09:00
bellman
4c939c0ad6 docs: close ROADMAP 465 evidence
465: doctor auth check now exposes openai_key_present

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:48:27 +09:00
bellman
4d41ab37e1 fix: expose openai_key_present in doctor auth check
check_auth_health data fields now include openai_key_present alongside
api_key_present and auth_token_present. any_auth_present already
includes OPENAI_API_KEY for prompt_ready status.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:46:24 +09:00
bellman
662a50bdc0 docs: close ROADMAP 347,356,357 evidence
347: mcp show missing returns status:error
356: status --help returns JSON
357: doctor --help returns JSON

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:38:38 +09:00
bellman
1da4aa454f docs: close ROADMAP 413,416 evidence
413: ACP JSON no longer leaks tracking IDs
416: plugins list returns structured plugins[] array

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:34:20 +09:00
bellman
3f50f33407 fix: filter boundary sentinel from system-prompt sections JSON
__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ is now filtered from the sections
array in JSON output. boundary_index field exposed for callers that
need the static/dynamic split point.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:32:14 +09:00
bellman
0ce4168c93 docs: close ROADMAP 419-420 evidence
419: MCP unknown sub-actions return typed error with exit 1
420: plugins help returns standard help envelope

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:25:12 +09:00
bellman
7f1dd0c116 docs: close ROADMAP 700-704 evidence
700: help JSON already has status field
701: doctor details already structured {key,value}
702: agents/skills both use source field
703: plugins list has structured summary
704: doctor checks have stable id field

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:21:07 +09:00
bellman
42f56e7f77 docs: close ROADMAP 458-459 evidence
458: status field now universal across all JSON envelopes
459: memory file discovery expanded with AGENTS.md/.claude/CLAUDE.md

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:17:37 +09:00
bellman
f25fae6d22 docs: mark ROADMAP 726-806 as DONE
71 items with verified fixes marked as DONE. All have Fix/Fix applied
sections with corresponding code evidence in the current codebase.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:13:56 +09:00
bellman
be66d961bf docs: close ROADMAP 705-722 evidence
All items already fixed in prior commits:
705: estimated_cost_usd_num companion field
706: skills show not-found error
707: init temp_dir AtomicU64 counter
708: skills show action field
709: duplicate status keys removed
710: diff action and working_directory
711: version/system-prompt/export/init action field
712: doctor/status/bootstrap-plan/dump-manifests action field
713: acp and config action field
714: help action and status fields
715: resume-path action and status fields
716: resume-path error JSON standard envelope
717: agents show implemented
718: plugins show implemented
719: plugins list filter
720: help <topic> routing
721: config sections support
722: rebase conflict resolved

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:06:39 +09:00
bellman
b1a40a2364 docs: close ROADMAP 694,698 evidence
694: pre-push cargo build gate already exists
698: config warning dedup already implemented

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:04:44 +09:00
bellman
726d55d1ea docs: close ROADMAP 682,693 evidence
682: agents already returns typed error for unknown subcommands
693: claw-analog already uses unknown_bootstrap_phase_error

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:03:07 +09:00
bellman
e4b8f9c07f fix: return typed error for unsupported MCP sub-actions
The catch-all in render_mcp_report_json_for now returns
render_mcp_unsupported_action_json with ok:false and
error_kind:unsupported_action instead of help JSON with exit 0.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 02:00:26 +09:00
bellman
b3a5a74237 fix: target multi-word guard for CLI subcommands only
Only fire the slash-command guard for multi-word commands when the
first token is a known CLI subcommand (help, version, status, etc.).
Slash commands with additional arguments (explain this, cost list)
are treated as prompts.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:46:11 +09:00
bellman
ad76389b31 docs: close ROADMAP 464,470 evidence
464: CliOutputFormat::parse already handles case/whitespace/hints
470: --reasoning-effort already uses invalid_flag_value prefix

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:40:45 +09:00
bellman
e68733d72e docs: close ROADMAP 462-463 evidence
462: build_date already present in version_json_value
463: classify_error_kind already returns removed_subcommand

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:38:32 +09:00
bellman
4d4d72cd49 fix: add prefix-aware matching to config key suggestion
When input is a prefix of a candidate (e.g., mcp → mcpServers), return
the prefix match directly instead of relying on edit-distance which
would incorrectly suggest env (distance 3) over mcpServers (distance 7).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:36:35 +09:00
bellman
9bc2f3631d fix: widen parse_subcommand guard for multi-word commands
Changed rest.len() != 1 guard to rest.is_empty() so claw cost list,
claw model list, claw permissions show, etc. now reach the
bare_slash_command_guidance guard and emit typed slash-command
guidance instead of falling through to CliAction::Prompt.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:29:36 +09:00
bellman
f40927ba97 docs: close ROADMAP 451-452 models already wired evidence
claw models is already wired as CliAction::Models with full
print_models implementation. Both models list and models help
return bounded JSON without touching provider runtime.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:27:27 +09:00
bellman
5bcbc2f874 docs: close ROADMAP 460 alias check evidence
bare_slash_command_guidance already checks both spec.name and
spec.aliases at line 2407.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:23:21 +09:00
bellman
a671969688 fix: handle --help after --resume flag
Added specific handler for --help when rest contains --resume, so
claw --resume --help shows resume help instead of consuming --help
as a session-id literal.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:18:55 +09:00
bellman
6757ebde74 fix: align discovered_config_files count with config check
The status_context function now filters loader.discover() to only count
paths that exist on disk, matching the check_config_health behavior.
Both config.discovered_files_count and workspace.discovered_config_files
now report the same number.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:17:06 +09:00
bellman
2447273d09 fix: add list to KNOWN_SUBCOMMANDS and close ROADMAP 454-455
Added list to KNOWN_SUBCOMMANDS so claw list is caught by typo
suggestion instead of falling through to CliAction::Prompt. Also
verified #455 (missing_credentials hint already newline-delimited).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:15:00 +09:00
bellman
5a76ecb760 fix: add prompt_ready to doctor auth check
Added prompt_ready:bool and prompt_blocked_reason:string|null to the
auth check in doctor --output-format json. prompt_ready is true when
any auth credential is present, false otherwise. prompt_blocked_reason
is auth_missing when prompt_ready is false.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:11:31 +09:00
bellman
db56498460 fix: route session list through credentials-free path
Added dedicated CliAction::SessionList variant for claw session list so
it no longer requires API credentials. run_session_list() calls
list_managed_sessions() directly without instantiating an API client.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:06:28 +09:00
bellman
6fcd0c57ae fix: clarify sandbox requested vs active state in JSON output
Added requested field (alias for enabled) and active_components object
with namespace/network/filesystem booleans for precise subsystem visibility.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 01:02:02 +09:00
bellman
de66bfc082 fix: route broad_cwd JSON error to stdout and close ROADMAP 446-447
The enforce_broad_cwd_policy function was sending JSON error envelopes
to stderr instead of stdout. Fixed to use println! for JSON mode,
matching the main error handler and all resume error paths.

Also closed ROADMAP #446 (config warning deduplication already handled
by emit_config_warning_once) and #447 (JSON errors already routed to
stdout in main handler).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:54:17 +09:00
bellman
5d85739358 fix: detect skill name/dir mismatch and report metadata drift
Skill discovery now tracks dir_name alongside frontmatter name and detects
when they differ. skills list --output-format json includes metadata_drift
array and reports degraded status when drift entries exist.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:36:04 +09:00
bellman
8fd11e82c4 fix: track skill directory name for name/dir mismatch detection
Adds dir_name field to SkillSummary to enable detection of skills where
the SKILL.md frontmatter name differs from the parent directory name.
Also adds SkillMetadataDrift struct for tracking mismatches (used by
skills JSON output in follow-up).

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:26:42 +09:00
bellman
9f9b14a76d fix: add broad-cwd guard to resume path
claw --resume now enforces the same broad-cwd safety policy as claw prompt
and the interactive REPL. Running from /, $HOME, or other broad directories
blocks execution unless --allow-broad-cwd is passed.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:23:28 +09:00
bellman
78c2a49ec8 docs: close ROADMAP 443 acp serve evidence 2026-06-05 00:12:23 +09:00
bellman
0e54ec4c04 fix: exit non-zero for acp serve and remove internal tracking IDs
claw acp serve now exits 2 (not implemented) instead of 0, so automation
pipelines can detect the no-op via exit code gating.

Key changes:
- acp serve exits 2 instead of 0
- Removed discoverability_tracking, tracking, recommended_workflows from JSON
- Removed phase, exit_code, serve_alias_only fields from JSON
- Status changed from unsupported/discoverability_only to not_implemented
- Error kind for unsupported ACP invocations uses typed prefix
- Updated tests to match new exit code and JSON structure

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 00:11:31 +09:00
bellman
58a30f6ab8 fix: accept markdown agent definitions with YAML frontmatter
Agent discovery now loads .md files with YAML frontmatter alongside .toml
files, matching the Claude Code agent definition convention. Markdown
agent files must have ----delimited YAML frontmatter with at least name
or description fields.

Key changes:
- parse_agent_frontmatter extracts name, description, model, model_reasoning_effort
- load_agents_from_roots_with_invalids collects both valid and invalid agents
- InvalidAgentConfig tracks rejected .md files with reason
- AgentCollection groups valid agents with invalid entries
- agents JSON output includes valid_count, invalid_count, invalid_agents
- Status is degraded when invalid agents exist

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 23:57:33 +09:00
bellman
453d8945bb fix: validate hook config entries partially
Hook config now supports the Claude Code structured hook format with
partial validation. Invalid hook entries are recorded in invalid_hooks
while valid siblings are retained, following the same pattern as MCP
partial validation (#440).

Key changes:
- RuntimeInvalidHookConfig now includes typed kind field (invalid_hooks_config
  or unknown_hook_event) for machine-readable error classification
- Hook parsing collects all invalid entries instead of halting at first error
- Unknown hook event names recorded as invalid without rejecting valid hooks
- Legacy bare-string hooks still load with deprecation warnings
- Claude Code documented format loads without error (matcher + nested hooks)
- config/status/doctor JSON surfaces hook_validation metadata
- classify_error_kind maps hook errors to invalid_hooks_config

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 23:42:58 +09:00
TheArchitectit
9e50cb6e20 Merge remote-tracking branch 'upstream/main' into worktree-api-timeout-retry-v2
# Conflicts:
#	rust/crates/runtime/src/config.rs
#	rust/crates/runtime/src/lib.rs
2026-06-04 09:17:43 -05:00
TheArchitectit
346772a8b3 test: add exclude_id and 0-message filtering coverage; cargo fmt 2026-06-04 09:17:42 -05:00
TheArchitectit
ef392e5938 Merge remote-tracking branch 'upstream/main' into worktree-session-resume-fixes 2026-06-04 09:15:19 -05:00
bellman
4619375c14 fix: load partial MCP configs
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 18:31:58 +09:00
bellman
10fe72498a fix: bound parent memory discovery
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 17:07:00 +09:00
bellman
5b22bc0480 fix: load Claw and Agents memory files
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 16:36:04 +09:00
bellman
ae7da0ec74 fix: expose complete version provenance
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 15:55:08 +09:00
bellman
7dd17c6344 fix: scaffold safe init settings
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 15:34:15 +09:00
bellman
d8535bf938 fix: keep failed resume side-effect free
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 15:08:56 +09:00
bellman
b45c61eff9 fix: recover parser contract CI
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 14:13:53 +09:00
bellman
7cfd83f66a test: align compact CI contract
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 13:43:02 +09:00
TheArchitectit
41034bb3f3 fix: address CI test failure and add empty-session error message
- Fix latest_session_alias_resolves_most_recent_managed_session test:
  the test created sessions with 0 messages, which are now filtered out
  by the message_count > 0 check in latest_session_excluding(). Updated
  the test to call push_user_text() before saving so sessions have
  at least one message and are findable by /resume latest.

- Add distinct error message when all sessions are empty (0 messages).
  Previously, the same "no managed sessions found" message was returned
  whether there were zero sessions or all sessions had 0 messages. Now:
  - No sessions at all → "no managed sessions found in {path}. Start
    claw to create a session..."
  - Sessions exist but all empty → "all sessions are empty (0 messages)
    in {path}. This usually means a fresh claw session is running but
    no messages have been sent yet. Wait for a response in your other
    session, then try --resume latest again."

- Add test for the all-sessions-empty error path.

  Addresses reviewer feedback on #3216.
2026-06-03 13:44:20 -05:00
TheArchitectit
76783377ec fix: address CI failures and reviewer feedback on #3214
- Add missing retry_after: None field to ApiError::Api construction
  in main.rs test. This field was introduced by the Retry-After
  header support but was not added to the test's error initializer,
  causing a compile error under CI's strict mode.

- Remove duplicate #[must_use] attribute on retry_after() method
  in error.rs (lines 134+138 both had it; kept the outer one
  above the doc comment per convention).

- Cargo fmt --all run.

- Reviewer question "Are defaults preserved?" — answered yes:
  ApiTimeoutConfig defaults to 30s connect / 300s request / 8 retries.
  with_retry_policy() is opt-in. No behavior change without explicit
  configuration.
2026-06-03 13:19:25 -05:00
TheArchitectit
e459a727e9 fix: session resume — skip current empty session, unify cross-workspace loading
Three improvements to the /resume command:

1. /resume latest now skips the current empty session
   When a new session is created on startup (with 0 messages), /resume
   latest previously returned that empty session. Now it skips sessions
   with message_count == 0 and excludes the current session ID via the
   new exclude_id parameter, so it finds the previous session with
   actual conversation history.

2. Unified load_session_excluding() replaces load_session_loose()
   The previous load_session_loose() only handled cross-workspace
   resume for aliases. The new load_session_excluding() combines the
   loose workspace validation logic with the exclude_id parameter,
   simplifying the call chain and ensuring all resume paths skip the
   current empty session when appropriate.

3. All existing session scanning paths (global root + project-local
   .claw/sessions/) are already in place from prior commits, and now
   the exclude_id filter is applied consistently across both local
   and global session scans.

Changes:
- session_control.rs: Add resolve_reference_excluding() that delegates
  from resolve_reference(), adding optional exclude_id filtering for
  alias references.
- session_control.rs: Add latest_session_excluding() that delegates
  from latest_session(), filtering out excluded session IDs and
  sessions with 0 messages in both local and global scan paths.
- session_control.rs: Add load_session_excluding() that replaces
  load_session_loose(), combining cross-workspace alias handling with
  the exclude_id parameter.
- main.rs: Add load_session_reference_excluding() that delegates from
  load_session_reference(), using the new store method.
- main.rs: Wire LiveCli::resume_session() to pass the current session
  ID as the exclude_id so /resume latest skips the current empty
  session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:49:21 -05:00
TheArchitectit
04bc5f5788 feat: API timeout config, Retry-After header, configurable retry, and 400 transient retry
Cherry-picked from PR #2816 onto current upstream/main, resolving
conflicts from PR #3015's merge (which added retry_after to ApiError
but some construction sites were missing it).

Commits preserved:
- ade85398: API timeout config, Retry-After header, configurable retry
  - TimeoutConfig in HTTP client builder (connect 30s, request 5min)
  - CLAW_API_CONNECT_TIMEOUT and CLAW_API_REQUEST_TIMEOUT env vars
  - Retry-After header parsing on 429 responses
  - ApiTimeoutConfig in runtime config (settings.json)
- 8a883430: retry 400 responses with transient gateway error bodies
  - Detects known gateway phrases in 400 response bodies
  - Marks them as retryable instead of hard-failing
- ed91a61e: add 'no parseable body' to CONTEXT_WINDOW_ERROR_MARKERS
  - Some providers return 400 with 'no parseable body' for oversized
    requests instead of a proper context_length_exceeded error

Commits skipped (already in upstream via PR #3015):
- 453ab642: optional id field (already merged)
- baa8d1ba: HTML detection in streaming (already merged)
- 33d2f789: JSON error detection in streaming (already merged)

8 files changed, 299 insertions, 80 deletions
2026-06-02 15:35:29 -05:00
TheArchitectit
571d3cdc0f fix: add "no parseable body" to CONTEXT_WINDOW_ERROR_MARKERS
Some OpenAI-compat backends (e.g. glm-5.1-fast) return 400 with
"no parseable body" when the request payload is too large to parse,
rather than a proper context_length_exceeded error. Without this marker,
is_context_window_error() returns false and the auto-compact retry
loop never triggers — the user just sees an opaque 400 error.

💘 Generated with Crush

Assisted-by: GLM 5.1 FP8 via Crush <crush@charm.land>
2026-06-02 15:31:04 -05:00
TheArchitectit
414a1aca4f fix: retry 400 responses with transient gateway error bodies
Some providers/proxies return HTTP 400 with bodies like "no parseable
body" or "connection reset" during transient network blips. These are
not real bad requests — they're gateway errors wearing a 400 mask.
Detect known gateway error phrases in 400 response bodies and mark
them as retryable so the existing exponential backoff handles them.
2026-06-02 15:30:41 -05:00
TheArchitectit
d8c57ed317 feat: API timeout config, Retry-After header support, and configurable retry
- Add TimeoutConfig to HTTP client builder with connect_timeout (30s)
  and request_timeout (5min) defaults, configurable via
  CLAW_API_CONNECT_TIMEOUT and CLAW_API_REQUEST_TIMEOUT env vars
- Add with_timeout() builder to both AnthropicClient and
  OpenAiCompatClient for per-client timeout configuration
- Parse Retry-After header on 429 responses and use it to override
  exponential backoff delay when present
- Add ApiTimeoutConfig to runtime config with apiTimeout settings
  in ~/.claw/settings.json (connectTimeout, requestTimeout, maxRetries)
- Add retry_after field to ApiError::Api for propagating rate limit
  backoff hints through the retry pipeline
2026-06-02 15:30:22 -05:00
Sam Lamrabte
e8c8ef1142 Harden permission enforcement against sandbox bypasses
Close two ways the permission system could be bypassed:

- Workspace path traversal: normalize `.`/`..` lexically before the
  boundary prefix comparison so paths like `/workspace/../../etc` can no
  longer escape the sandbox. Fixed in both the runtime enforcer and the
  duplicate check in the tools PowerShell path classifier.
- read-only mode no longer trusts the leading token alone: reject shell
  metacharacters (chaining/substitution/redirect/pipe/subshell), drop
  interpreters and build drivers (python/node/ruby/cargo/rustc) from the
  allow-list, gate `git` to non-mutating subcommands, and reject `find`
  actions that execute or delete.

Adds regression tests for both holes. The pre-existing, unrelated
worker_boot git-metadata test failure is not affected by this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:26:39 +02:00
Petter Reinholdtsen
1d516be779 fix: recover from llama.cpp context overflow and reqwest SSE decode failures
Extend auto-compaction error detection to handle additional error patterns
from llama.cpp backends: 'Context size has been exceeded',
'exceed_context_size_error', 'exceeds the available context size'. Also
recover from reqwest 'error decoding response body' errors — some
llama.cpp instances return a non-SSE plaintext HTTP 500 on context overflow,
causing the SSE deserializer to fail.

Add dynamic threshold adaptation: parse server-reported context window
size from error messages (e.g., '(81920 tokens)') and set the auto-
compaction trigger at 70% of that value. This replaces the need for a
hardcoded threshold, adapting automatically to any backend's limits.

This patch was developed with assistance from OpenCode and local Qwen 3.6
API server.
2026-05-27 16:57:59 +02:00
29 changed files with 5037 additions and 1132 deletions

View File

@@ -226,6 +226,7 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain:
- [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)
- [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode)
- [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex)
- [gajae-code](https://github.com/Yeachan-Heo/gajae-code)
- [UltraWorkers Discord](https://discord.gg/5TUQKqFWd)
## Ownership / affiliation disclaimer

File diff suppressed because one or more lines are too long

View File

@@ -51,26 +51,27 @@ cd rust
```
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. `status --output-format json` exposes `workspace.memory_files[]` with `path`, `source`, `origin`, `scope_path`, `outside_project`, `chars`, and `contributes` for every loaded project memory file.
### Initialize a repository
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
Set up a new repository with `.claw/settings.json`, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
```bash
cd /path/to/your/repo
./target/debug/claw init
```
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped", reports `.claw/` as "partial" when missing sub-files are materialized, and keeps `.claw/sessions/` deferred until the first successful session save.
JSON mode for scripting:
```bash
./target/debug/claw init --output-format json
```
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
Returns structured output with `project_path`, `created[]`, `updated[]`, `partial[]`, `deferred[]`, and `skipped[]` arrays (one per artifact status), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
**Why structured fields matter:** Claws can detect per-artifact state (`created`, `updated`, `partial`, `deferred`, or `skipped`) without substring-matching human prose. Use the status arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
### Interactive REPL
@@ -244,6 +245,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
| Ollama local instance | `OLLAMA_HOST` | no auth header (Ollama requires none) | local Ollama server at `http://127.0.0.1:11434` |
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
@@ -304,18 +306,18 @@ cd rust
### Ollama
```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
export OLLAMA_HOST="http://127.0.0.1:11434"
cd rust
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
```
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), `OPENAI_BASE_URL` selects the local OpenAI-compatible route even when `OPENAI_API_KEY` is unset:
`OLLAMA_HOST` is the preferred env var. Claw routes all models to the local Ollama endpoint automatically, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported.
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), both approaches work:
```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
export OLLAMA_HOST="http://127.0.0.1:11434"
cd rust
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"
@@ -569,6 +571,30 @@ Runtime config is loaded in this order, with later entries overriding earlier on
The list is also the precedence chain: project-local settings override project settings, project settings override the legacy project `.claw.json`, and project files override user files. `claw --output-format json config` includes each discovered file's `precedence_rank`, `wins_for_keys`, and `shadowed_keys` so automation can see which file controls each effective key without reimplementing the merge order.
## MCP server validation
`claw mcp --output-format json` loads valid `mcpServers` entries even when sibling entries are malformed. The JSON list envelope distinguishes the total configured entries from the valid and invalid subsets:
```json
{
"configured_servers": 1,
"total_configured": 2,
"valid_count": 1,
"invalid_count": 1,
"servers": [{ "name": "valid-server", "valid": true }],
"invalid_servers": [
{
"name": "missing-command",
"error_field": "command",
"reason": ".claw.json: mcpServers.missing-command: missing string field command",
"valid": false
}
]
}
```
`status --output-format json` mirrors this under `mcp_validation`, and `doctor --output-format json` includes an `mcp validation` check so automation can repair every rejected server entry without losing usable MCP servers.
## Hook configuration
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
@@ -590,14 +616,17 @@ The list is also the precedence chain: project-local settings override project s
```
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
Legacy bare-string hook entries still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid without rejecting valid hooks. `status --output-format json` mirrors partial hook validation under `hook_validation` with `valid_count`, `invalid_count`, and `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]`. `doctor --output-format json` includes a `hook validation` check so automation can repair every rejected hook entry without losing usable hooks.
## Project instruction rules
In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
In addition to root instruction files such as `CLAUDE.md`, `CLAW.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md` for each discovered directory. Discovery is bounded to the current git root when one exists, otherwise to the current directory only, so stale parent files outside the project do not silently bleed into the prompt. All loaded files contribute to the system prompt and to `status --output-format json` as `workspace.memory_files:[{path, source, origin, scope_path, outside_project, chars, contributes}]`; `claw doctor --output-format json` includes a `memory` check so automation can detect loaded and unexpected unloaded memory-file candidates without parsing prompt text.
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
```json

View File

@@ -57,11 +57,12 @@ ollama serve
In another shell:
```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
export OLLAMA_HOST="http://127.0.0.1:11434"
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
```
`OLLAMA_HOST` is the preferred env var for Ollama. Claw routes all models to the local OpenAI-compatible endpoint automatically when this is set, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported for existing setups.
If Ollama is running without auth, `unset OPENAI_API_KEY` is acceptable. Use a placeholder token rather than a real cloud API key if your local server requires an Authorization header.
## llama.cpp server

View File

@@ -87,7 +87,7 @@ Primary artifacts:
| Sub-agent / agent surfaces | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ |
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ |
@@ -148,6 +148,10 @@ Top-level commands:
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.

View File

@@ -32,16 +32,25 @@ impl ProviderClient {
OpenAiCompatConfig::xai(),
)?)),
ProviderKind::OpenAi => {
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
let config = match providers::metadata_for_model(&resolved_model) {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
))
} else {
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
let config = match providers::metadata_for_model(&resolved_model) {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
}
}
}
}

View File

@@ -20,6 +20,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"completion tokens",
"prompt tokens",
"request is too large",
"no parseable body",
];
#[derive(Debug)]
@@ -60,6 +61,9 @@ pub enum ApiError {
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>,
},
RetriesExhausted {
attempts: u32,
@@ -128,6 +132,17 @@ impl ApiError {
}
#[must_use]
/// Return the `Retry-After` delay if this error came from a 429 response
/// that included a `retry-after` header. Callers should prefer this value
/// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
@@ -311,6 +326,36 @@ impl Display for ApiError {
f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
// #28: enhance 401/403 errors with actionable auth guidance
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} if matches!(status.as_u16(), 401 | 403) => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")?;
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")?;
}
write!(
f,
"\nhint: check that your API key is valid and matches the target provider. \
For OpenAI-compatible providers set OPENAI_API_KEY or OPENAI_BASE_URL. \
For Anthropic set ANTHROPIC_API_KEY. \
Run `claw doctor` to verify your credential configuration."
)
}
Self::Api {
status,
error_type,
@@ -499,6 +544,7 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
};
assert!(error.is_generic_fatal_wrapper());
@@ -522,6 +568,7 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
}),
};
@@ -543,6 +590,7 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());
@@ -563,6 +611,7 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());

View File

@@ -1,9 +1,69 @@
use std::time::Duration;
use crate::error::ApiError;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
/// Timeout configuration for outbound HTTP requests.
///
/// When set, the `reqwest::Client` will abort requests that take longer
/// than the configured duration and return a timeout error (which is
/// retryable by the existing exponential backoff logic).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeoutConfig {
/// Maximum time to wait for a connection to be established.
/// Defaults to 30 seconds.
pub connect_timeout: Duration,
/// Maximum time for the entire request (including reading the response
/// body). For streaming responses this is the timeout for the initial
/// handshake only; the stream itself is governed by SSE parsing.
/// Defaults to 5 minutes (300 seconds).
pub request_timeout: Duration,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(30),
request_timeout: Duration::from_secs(300),
}
}
}
impl TimeoutConfig {
/// Read timeout settings from the process environment.
/// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds
/// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds
#[must_use]
pub fn from_env() -> Self {
let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(30));
let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300));
Self {
connect_timeout,
request_timeout,
}
}
/// Create from explicit second values (used by config file parsing).
#[must_use]
pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self {
Self {
connect_timeout: Duration::from_secs(connect_secs),
request_timeout: Duration::from_secs(request_secs),
}
}
}
/// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and
/// test the resolved configuration without re-reading the process environment.
@@ -61,7 +121,7 @@ impl ProxyConfig {
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
build_http_client_with(&ProxyConfig::from_env())
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
}
/// Infallible counterpart to [`build_http_client`] for constructors that
@@ -71,12 +131,13 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
/// first outbound request instead of at construction time.
#[must_use]
pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client().unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
.unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
}
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
@@ -86,9 +147,20 @@ pub fn build_http_client_or_default() -> reqwest::Client {
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(config, &TimeoutConfig::from_env())
}
/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`].
/// Used by callers that want to control both proxy routing and request timing.
pub fn build_http_client_with_opts(
config: &ProxyConfig,
timeout: &TimeoutConfig,
) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder()
.no_proxy()
.user_agent("clawd-rust-tools/0.1");
.user_agent("clawd-rust-tools/0.1")
.connect_timeout(timeout.connect_timeout)
.timeout(timeout.request_timeout);
let no_proxy = config
.no_proxy
@@ -131,7 +203,7 @@ where
mod tests {
use std::collections::HashMap;
use super::{build_http_client_with, ProxyConfig};
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs
@@ -143,30 +215,19 @@ mod tests {
#[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]);
// when
let empty = config.is_empty();
// then
assert!(empty);
assert!(config.is_empty());
assert_eq!(config, ProxyConfig::default());
}
#[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://proxy.internal:3128")
@@ -184,17 +245,12 @@ mod tests {
#[test]
fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [
("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://lower.internal:3128")
@@ -208,16 +264,11 @@ mod tests {
#[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://upper.internal:3128")
@@ -226,59 +277,39 @@ mod tests {
#[test]
fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none());
}
#[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig {
http_proxy: None,
https_proxy: Some("not a url".to_string()),
no_proxy: None,
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!(
matches!(error, crate::error::ApiError::Http(_)),
@@ -288,10 +319,7 @@ mod tests {
#[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!(
config.proxy_url.as_deref(),
Some("http://unified.internal:3128")
@@ -303,49 +331,56 @@ mod tests {
#[test]
fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()),
..ProxyConfig::default()
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()),
};
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config);
// then
assert!(
matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}"
);
}
#[test]
fn timeout_config_defaults() {
let config = TimeoutConfig::default();
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(300));
}
#[test]
fn timeout_config_from_seconds() {
let config = TimeoutConfig::from_seconds(10, 60);
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(60));
}
#[test]
fn build_http_client_with_custom_timeouts() {
let config = ProxyConfig::default();
let timeout = TimeoutConfig::from_seconds(5, 120);
let result = build_http_client_with_opts(&config, &timeout);
assert!(result.is_ok());
}
}

View File

@@ -12,7 +12,8 @@ pub use client::{
};
pub use error::ApiError;
pub use http_client::{
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
build_http_client, build_http_client_or_default, build_http_client_with,
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
};
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,

View File

@@ -211,6 +211,19 @@ impl AnthropicClient {
self
}
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration. This controls connect and request-level
/// timeouts for all outbound API calls.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
#[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer);
@@ -454,7 +467,13 @@ impl AnthropicClient {
break;
}
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after())
{
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
}
Err(ApiError::RetriesExhausted {
@@ -866,10 +885,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let request_id = request_id_from_headers(response.headers());
let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
let body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
Err(ApiError::Api {
status,
@@ -883,13 +904,44 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body,
retryable,
suggested_action: None,
retry_after,
})
}
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Some providers return HTTP 400 with an unparseable body when a gateway
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
/// These are transient network blips, not actual bad requests, and should
/// be retried. We detect them by checking the body for known gateway error
/// phrases.
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
if status != reqwest::StatusCode::BAD_REQUEST {
return false;
}
let lowered = body.to_ascii_lowercase();
lowered.contains("no parseable body")
|| lowered.contains("connection reset")
|| lowered.contains("broken pipe")
|| lowered.contains("empty reply from server")
}
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
@@ -908,6 +960,8 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
..
} = error
else {
return error;
@@ -921,6 +975,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
}
let Some(bearer_token) = auth.bearer_token() else {
@@ -932,6 +987,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
};
if !bearer_token.starts_with("sk-ant-") {
@@ -943,6 +999,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
}
// Only append the hint when the AuthSource is pure BearerToken. If both
@@ -958,6 +1015,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
}
let enriched_message = match message {
@@ -972,6 +1030,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
}
}
@@ -1596,6 +1655,7 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
@@ -1637,6 +1697,7 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
};
// when
@@ -1666,6 +1727,7 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
@@ -1694,6 +1756,7 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
@@ -1719,6 +1782,7 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when

View File

@@ -351,6 +351,11 @@ fn looks_like_local_openai_model(model: &str) -> bool {
#[must_use]
pub fn detect_provider_kind(model: &str) -> ProviderKind {
// OLLAMA_HOST takes priority: if set, route all models through the local
// OpenAI-compatible endpoint regardless of model name or other env vars.
if std::env::var_os("OLLAMA_HOST").is_some() {
return ProviderKind::OpenAi;
}
let resolved_model = resolve_model_alias(model);
if let Some(metadata) = metadata_for_model(&resolved_model) {
return metadata.provider;

View File

@@ -49,6 +49,14 @@ const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood)
pub const OLLAMA_CONFIG: OpenAiCompatConfig = OpenAiCompatConfig {
provider_name: "Ollama",
api_key_env: "OLLAMA_HOST",
base_url_env: "OLLAMA_HOST",
default_base_url: "http://127.0.0.1:11434/v1",
max_request_body_bytes: 104_857_600,
};
impl OpenAiCompatConfig {
#[must_use]
pub const fn xai() -> Self {
@@ -149,6 +157,22 @@ impl OpenAiCompatClient {
};
Ok(Self::new(api_key, config).with_base_url(base_url))
}
/// Create an Ollama client from `OLLAMA_HOST` env var.
/// Ollama requires no API key; a placeholder is used for the Authorization header.
pub fn from_ollama_env() -> Option<Self> {
let host =
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string());
let base_url = format!("{}/v1", host.trim_end_matches('/'));
Some(Self {
http: build_http_client_or_default(),
api_key: "ollama".to_string(),
config: OLLAMA_CONFIG,
base_url,
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
})
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
@@ -175,6 +199,18 @@ impl OpenAiCompatClient {
self
}
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
pub async fn send_message(
&self,
request: &MessageRequest,
@@ -217,6 +253,7 @@ impl OpenAiCompatClient {
reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
),
retry_after: None,
});
}
}
@@ -270,7 +307,12 @@ impl OpenAiCompatClient {
break retryable_error;
}
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
let delay = if let Some(retry_after) = retryable_error.retry_after() {
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
};
Err(ApiError::RetriesExhausted {
@@ -1561,6 +1603,7 @@ fn parse_sse_frame(
body: trimmed.chars().take(500).collect(),
retryable: false,
suggested_action: suggested_action_for_status(status),
retry_after: None,
});
}
}
@@ -1576,6 +1619,7 @@ fn parse_sse_frame(
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None);
@@ -1611,6 +1655,7 @@ fn parse_sse_frame(
body: payload.clone(),
retryable: false,
suggested_action: suggested_action_for_status(status),
retry_after: None,
});
}
}
@@ -1627,6 +1672,7 @@ fn parse_sse_frame(
body: payload.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
serde_json::from_str::<ChatCompletionChunk>(&payload)
@@ -1678,10 +1724,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let request_id = request_id_from_headers(response.headers());
let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
let body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
let suggested_action = suggested_action_for_status(status);
@@ -1697,13 +1745,43 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body,
retryable,
suggested_action,
retry_after,
})
}
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Some providers return HTTP 400 with an unparseable body when a gateway
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
/// These are transient network blips, not actual bad requests, and should
/// be retried.
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
if status != reqwest::StatusCode::BAD_REQUEST {
return false;
}
let lowered = body.to_ascii_lowercase();
lowered.contains("no parseable body")
|| lowered.contains("connection reset")
|| lowered.contains("broken pipe")
|| lowered.contains("empty reply from server")
}
/// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail.
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -118,10 +118,7 @@ impl FieldType {
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => value.as_array().is_some_and(|arr| {
arr.iter()
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
}),
Self::HookArray => true,
Self::RulesImport => {
value.as_str().is_some()
|| value
@@ -439,8 +436,56 @@ fn validate_object_keys(
result
}
/// Emit deprecation warnings for bare string hook entries in the hooks object.
/// Legacy `["command-string"]` arrays still load but suggest migration to the
/// structured `{matcher, hooks:[{type, command}]}` form.
fn validate_hook_entry_format(
hooks: &BTreeMap<String, JsonValue>,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
for spec in HOOKS_FIELDS {
let Some(value) = hooks.get(spec.name) else {
continue;
};
let Some(array) = value.as_array() else {
continue;
};
for item in array {
if item.as_str().is_some() {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: format!("hooks.{}", spec.name),
line: find_key_line(source, spec.name),
kind: DiagnosticKind::Deprecated {
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
},
});
// One deprecation warning per event is enough
break;
}
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase();
// #461: prefix-aware matching — if input is a prefix of a candidate,
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
let prefix_match = candidates
.iter()
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
.min_by_key(|c| c.len())
.map(|name| name.to_string());
if prefix_match.is_some() {
return prefix_match;
}
candidates
.iter()
.filter_map(|candidate| {
@@ -510,6 +555,7 @@ pub fn validate_config_file(
source,
&path_display,
));
result.merge(validate_hook_entry_format(hooks, source, &path_display));
}
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
@@ -714,7 +760,7 @@ mod tests {
#[test]
fn validates_nested_hooks_keys() {
// given
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
@@ -723,7 +769,12 @@ mod tests {
// then
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(
result.warnings.len(),
1,
"expected only the unknown key warning, got {:?}",
result.warnings
);
assert_eq!(result.warnings[0].field, "hooks.BadHook");
}
@@ -739,15 +790,14 @@ mod tests {
}
#[test]
fn rejects_wrong_hook_entry_types() {
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
@@ -847,7 +897,7 @@ mod tests {
// given
let source = r#"{
"model": "opus",
"hooks": {"PreToolUse": ["guard"]},
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {},
"sandbox": {"enabled": false}

View File

@@ -204,6 +204,13 @@ where
self
}
/// Update the auto-compaction threshold after construction. This allows the
/// caller to tune the threshold based on runtime information (e.g., the
/// server-returned context window size from a 400 error).
pub fn set_auto_compaction_input_tokens_threshold(&mut self, threshold: u32) {
self.auto_compaction_input_tokens_threshold = threshold;
}
#[must_use]
pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self {
self.hook_abort_signal = hook_abort_signal;

View File

@@ -65,13 +65,14 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePermissionRuleConfig,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -142,8 +143,9 @@ pub use policy_engine::{
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,

View File

@@ -173,32 +173,112 @@ impl PermissionEnforcer {
}
}
/// Simple workspace boundary check via string prefix.
/// Workspace boundary check.
///
/// Resolves `.` and `..` components lexically *before* comparing against the
/// workspace root, so that traversal sequences like `/workspace/../../etc`
/// cannot escape the sandbox via a naive string prefix match. Normalization is
/// lexical (it does not touch the filesystem) because the target path may not
/// exist yet on a write, and we must not depend on CWD.
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
let normalized = if path.starts_with('/') {
let combined = if path.starts_with('/') {
path.to_owned()
} else {
format!("{workspace_root}/{path}")
};
let root = if workspace_root.ends_with('/') {
workspace_root.to_owned()
let normalized = lexically_normalize(&combined);
let root = lexically_normalize(workspace_root);
let root_with_slash = if root.ends_with('/') {
root.clone()
} else {
format!("{workspace_root}/")
format!("{root}/")
};
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
normalized == root || normalized.starts_with(&root_with_slash)
}
/// Collapse `.` and `..` segments without consulting the filesystem.
/// `..` that would climb above an absolute root is clamped at `/`, so the
/// result can never be a prefix-match for a deeper workspace root.
fn lexically_normalize(path: &str) -> String {
let is_absolute = path.starts_with('/');
let mut stack: Vec<&str> = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
let joined = stack.join("/");
if is_absolute {
format!("/{joined}")
} else {
joined
}
}
/// Conservative heuristic: is this bash command read-only?
///
/// Hardening notes:
/// - Any shell metacharacter that could chain, substitute, pipe, or redirect
/// into a state-changing command rejects the whole line. This blocks
/// `cat x; rm -rf y`, `cat x | sh`, `$(...)`, backticks, redirects, and
/// subshells regardless of the leading token.
/// - Language interpreters (`python`, `node`, `ruby`) and build drivers
/// (`cargo`, `rustc`) are NOT read-only: they execute arbitrary code, so they
/// are excluded from the allow-list.
/// - `git` is allowed only for a known set of non-mutating subcommands.
/// - `find` is rejected when it carries an action that can execute or delete.
///
/// Residual known gaps (documented, not yet closed): `sed`'s `w`/`e` script
/// commands and `awk`'s `system()` can still mutate — these require quoting or
/// metacharacters that the checks above usually catch, but a dedicated parser
/// would be more robust. Tracked as follow-up.
fn is_read_only_command(command: &str) -> bool {
let first_token = command
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
// Shell metacharacters that enable command chaining, substitution,
// piping, redirection, or subshells. Presence of any of these means we
// cannot reason about the command from its leading token alone.
const SHELL_METACHARS: &[char] = &[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
if command.contains(SHELL_METACHARS) {
return false;
}
let mut tokens = command.split_whitespace();
let first_token = tokens.next().unwrap_or("").rsplit('/').next().unwrap_or("");
// `git` is only read-only for a curated set of subcommands.
if first_token == "git" {
let subcommand = tokens.next().unwrap_or("");
return matches!(
subcommand,
"status"
| "log"
| "diff"
| "show"
| "branch"
| "rev-parse"
| "ls-files"
| "blame"
| "describe"
| "tag"
| "remote"
);
}
// `find` can execute or delete via actions; reject those forms.
if first_token == "find"
&& (command.contains("-exec")
|| command.contains("-execdir")
|| command.contains("-delete")
|| command.contains("-ok")
|| command.contains("-fprintf"))
{
return false;
}
matches!(
first_token,
@@ -237,8 +317,6 @@ fn is_read_only_command(command: &str) -> bool {
| "tr"
| "cut"
| "paste"
| "tee"
| "xargs"
| "test"
| "true"
| "false"
@@ -257,18 +335,8 @@ fn is_read_only_command(command: &str) -> bool {
| "tree"
| "jq"
| "yq"
| "python3"
| "python"
| "node"
| "ruby"
| "cargo"
| "rustc"
| "git"
| "gh"
) && !command.contains("-i ")
&& !command.contains("--in-place")
&& !command.contains(" > ")
&& !command.contains(" >> ")
}
#[cfg(test)]
@@ -375,6 +443,91 @@ mod tests {
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
}
// --- Hardening regression tests (#2: read-only bypasses) ---
#[test]
fn read_only_rejects_command_chaining() {
// A leading read-only token must not launder a trailing destructive one.
assert!(!is_read_only_command("cat foo; rm -rf bar"));
assert!(!is_read_only_command("cat foo && rm -rf bar"));
assert!(!is_read_only_command("ls || rm bar"));
assert!(!is_read_only_command("cat foo | sh"));
assert!(!is_read_only_command("echo `rm bar`"));
assert!(!is_read_only_command("echo $(rm bar)"));
assert!(!is_read_only_command("echo x>file")); // redirect without spaces
}
#[test]
fn read_only_rejects_interpreters_and_build_drivers() {
// These execute arbitrary code and are no longer read-only.
assert!(!is_read_only_command(
"python3 -c \"import os; os.system('rm -rf .')\""
));
assert!(!is_read_only_command("python script.py"));
assert!(!is_read_only_command("node app.js"));
assert!(!is_read_only_command("ruby x.rb"));
assert!(!is_read_only_command("cargo run"));
assert!(!is_read_only_command("rustc evil.rs"));
}
#[test]
fn read_only_gates_git_subcommands() {
// Read-only git subcommands remain allowed...
assert!(is_read_only_command("git status"));
assert!(is_read_only_command("git diff HEAD~1"));
assert!(is_read_only_command("git show abc123"));
// ...but mutating/exfiltrating ones are rejected.
assert!(!is_read_only_command("git commit -m x"));
assert!(!is_read_only_command("git push origin main"));
assert!(!is_read_only_command("git reset --hard"));
assert!(!is_read_only_command("git clean -fd"));
assert!(!is_read_only_command("git config user.email a@b.c"));
}
#[test]
fn read_only_rejects_find_actions() {
assert!(is_read_only_command("find . -name Cargo.toml"));
assert!(!is_read_only_command("find . -delete"));
// -exec uses braces/semicolon which also trip the metachar guard,
// but the explicit action check is the primary defense.
assert!(!is_read_only_command("find . -execdir rm rf"));
}
// --- Hardening regression tests (#1: workspace path traversal) ---
#[test]
fn workspace_rejects_parent_traversal() {
assert!(!is_within_workspace(
"/workspace/../etc/passwd",
"/workspace"
));
assert!(!is_within_workspace(
"/workspace/../../etc/crontab",
"/workspace"
));
assert!(!is_within_workspace("../etc/passwd", "/workspace"));
assert!(!is_within_workspace(
"/workspace/sub/../../outside",
"/workspace"
));
// Legitimate paths still resolve inside.
assert!(is_within_workspace(
"/workspace/./src/main.rs",
"/workspace"
));
assert!(is_within_workspace(
"/workspace/src/../src/main.rs",
"/workspace"
));
}
#[test]
fn workspace_write_denies_traversal_escape() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/workspace/../../etc/crontab", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn active_mode_returns_policy_mode() {
// given

View File

@@ -149,7 +149,12 @@ impl PermissionPolicy {
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.denied_tools = config.denied_tools().to_vec();
// #94: normalize denied tool names to lowercase to match runtime convention
self.denied_tools = config
.denied_tools()
.iter()
.map(|t| t.to_lowercase())
.collect();
self
}
@@ -375,7 +380,8 @@ impl PermissionRule {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
tool_name: tool_name.to_string(),
// #94: normalize tool name to lowercase to match runtime convention
tool_name: tool_name.to_lowercase(),
matcher,
};
}
@@ -384,7 +390,8 @@ impl PermissionRule {
Self {
raw: trimmed.to_string(),
tool_name: trimmed.to_string(),
// #94: normalize tool name to lowercase to match runtime convention
tool_name: trimmed.to_lowercase(),
matcher: PermissionRuleMatcher::Any,
}
}

View File

@@ -69,6 +69,18 @@ pub struct ContextFile {
pub content: String,
}
impl ContextFile {
#[must_use]
pub fn source(&self) -> &'static str {
instruction_file_source(&self.path)
}
#[must_use]
pub fn char_count(&self) -> usize {
self.content.chars().count()
}
}
/// Project-local context injected into the rendered system prompt.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectContext {
@@ -256,22 +268,36 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect()
}
fn instruction_file_source(path: &Path) -> &'static str {
let file_name = path.file_name().and_then(|name| name.to_str());
let parent_name = path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str());
match (parent_name, file_name) {
(Some(".claw"), Some("CLAUDE.md")) => "claw_claude_md",
(Some(".claude"), Some("CLAUDE.md")) => "claude_claude_md",
(_, Some("CLAUDE.md")) => "claude_md",
(_, Some("CLAW.md")) => "claw_md",
(_, Some("AGENTS.md")) => "agents_md",
(_, Some("CLAUDE.local.md")) => "claude_local_md",
(Some(".claw"), Some("instructions.md")) => "claw_instructions",
_ => "rule_file",
}
}
fn discover_instruction_files(
cwd: &Path,
rules_import: &RulesImportConfig,
) -> std::io::Result<Vec<ContextFile>> {
let mut directories = Vec::new();
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
directories.push(dir.to_path_buf());
cursor = dir.parent();
}
let mut directories = instruction_discovery_dirs(cwd);
directories.reverse();
let mut files = Vec::new();
for dir in directories {
for candidate in [
dir.join("CLAUDE.md"),
dir.join("CLAW.md"),
dir.join("AGENTS.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"),
@@ -287,6 +313,32 @@ fn discover_instruction_files(
Ok(dedupe_instruction_files(files))
}
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
let mut directories = Vec::new();
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
directories.push(dir.to_path_buf());
if dir == boundary {
break;
}
cursor = dir.parent();
}
directories
}
fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
let git_marker = dir.join(".git");
if git_marker.is_dir() || git_marker.is_file() {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
if path.is_dir() {
return Ok(());
@@ -430,7 +482,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
];
if !project_context.instruction_files.is_empty() {
bullets.push(format!(
"Claude instruction files discovered: {}.",
"Project instruction files discovered: {}.",
project_context.instruction_files.len()
));
}
@@ -465,7 +517,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
}
fn render_instruction_files(files: &[ContextFile]) -> String {
let mut sections = vec!["# Claude instructions".to_string()];
let mut sections = vec!["# Project instructions".to_string()];
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files {
if remaining_chars == 0 {
@@ -573,16 +625,31 @@ pub fn load_system_prompt(
os_version: impl Into<String>,
model_family: ModelFamilyIdentity,
) -> Result<Vec<String>, PromptBuildError> {
let cwd = cwd.into();
let (sections, _) =
load_system_prompt_with_context(cwd, current_date, os_name, os_version, model_family)?;
Ok(sections)
}
/// Loads config and project context, then renders the system prompt text plus metadata.
pub fn load_system_prompt_with_context(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
os_name: impl Into<String>,
os_version: impl Into<String>,
model_family: ModelFamilyIdentity,
) -> Result<(Vec<String>, ProjectContext), PromptBuildError> {
let cwd = cwd.into();
let config = ConfigLoader::default_for(&cwd).load()?;
let project_context =
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
Ok(SystemPromptBuilder::new()
let sections = SystemPromptBuilder::new()
.with_os(os_name, os_version)
.with_model_family(model_family)
.with_project_context(project_context)
.with_project_context(project_context.clone())
.with_runtime_config(config)
.build())
.build();
Ok((sections, project_context))
}
fn render_config_section(config: &RuntimeConfig) -> String {
@@ -766,6 +833,7 @@ mod tests {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
fs::create_dir(root.join(".git")).expect("git boundary");
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions");
@@ -844,10 +912,11 @@ mod tests {
}
#[test]
fn discovers_claude_agents_and_dot_claude_instruction_files_together() {
fn discovers_claude_claw_agents_and_dot_claude_instruction_files_together() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
fs::write(root.join("CLAW.md"), "claw instructions").expect("write CLAW.md");
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
fs::write(
root.join(".claude").join("CLAUDE.md"),
@@ -857,8 +926,18 @@ mod tests {
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
let sources = context
.instruction_files
.iter()
.map(ContextFile::source)
.collect::<Vec<_>>();
assert_eq!(
sources,
vec!["claude_md", "claw_md", "agents_md", "claude_claude_md"]
);
assert!(rendered.contains("claude instructions"));
assert!(rendered.contains("claw instructions"));
assert!(rendered.contains("agents instructions"));
assert!(rendered.contains("dot claude instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
@@ -869,6 +948,7 @@ mod tests {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(&nested).expect("nested dir");
fs::create_dir(root.join(".git")).expect("git boundary");
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
@@ -881,6 +961,50 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovery_stops_at_git_root_boundary_439() {
let root = temp_dir();
let repo = root.join("repo");
let nested = repo.join("subproj").join("deep").join("nest");
fs::create_dir_all(&nested).expect("nested dir");
fs::create_dir(repo.join(".git")).expect("git boundary");
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
fs::write(
repo.join("subproj").join("deep").join("CLAUDE.md"),
"DEEP_CLAUDE",
)
.expect("write deep");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(!rendered.contains("PARENT_CLAUDE"));
assert!(rendered.contains("REPO_CLAUDE"));
assert!(rendered.contains("CHILD_CLAUDE"));
assert!(rendered.contains("DEEP_CLAUDE"));
assert_eq!(context.instruction_files.len(), 3);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovery_without_git_root_stays_cwd_local_439() {
let root = temp_dir();
let nested = root.join("scratch");
fs::create_dir_all(&nested).expect("nested dir");
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
fs::write(nested.join("CLAUDE.md"), "SCRATCH_CLAUDE").expect("write scratch");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(!rendered.contains("PARENT_CLAUDE"));
assert!(rendered.contains("SCRATCH_CLAUDE"));
assert_eq!(context.instruction_files.len(), 1);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn truncates_large_instruction_content_for_rendering() {
let rendered = render_instruction_content(&"x".repeat(4500));
@@ -1218,7 +1342,7 @@ mod tests {
assert!(prompt.contains("# System"));
assert!(prompt.contains("# Project context"));
assert!(prompt.contains("# Claude instructions"));
assert!(prompt.contains("# Project instructions"));
assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode"));
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
@@ -1263,7 +1387,7 @@ mod tests {
path: PathBuf::from("/tmp/project/CLAUDE.md"),
content: "Project rules".to_string(),
}]);
assert!(rendered.contains("# Claude instructions"));
assert!(rendered.contains("# Project instructions"));
assert!(rendered.contains("scope: /tmp/project"));
assert!(rendered.contains("Project rules"));
}

View File

@@ -231,8 +231,31 @@ impl Session {
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
let path = path.as_ref();
let snapshot = self.render_jsonl_snapshot()?;
rotate_session_file_if_needed(path)?;
write_atomic(path, &snapshot)?;
// #112: wrap ENOENT during rotate as concurrent modification
match rotate_session_file_if_needed(path) {
Ok(()) => {}
Err(SessionError::Io(ref io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
return Err(SessionError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"session file was removed during save (possible concurrent modification): {io_err}"
),
)));
}
Err(e) => return Err(e),
}
write_atomic(path, &snapshot).map_err(|e| {
// #112: wrap ENOENT during write as concurrent modification
match &e {
SessionError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
SessionError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("session file was removed during write (possible concurrent modification): {io_err}"),
))
}
_ => e,
}
})?;
cleanup_rotated_logs(path)?;
Ok(())
}

View File

@@ -28,7 +28,8 @@ pub struct SessionStore {
impl SessionStore {
/// Build a store from the server's current working directory.
///
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
/// The on-disk layout is `<cwd>/.claw/sessions/<workspace_hash>/`,
/// created lazily on first successful session save.
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
let cwd = cwd.as_ref();
// #151: canonicalize so equivalent paths (symlinks, relative vs
@@ -40,7 +41,6 @@ impl SessionStore {
.join(".claw")
.join("sessions")
.join(workspace_fingerprint(&canonical_cwd));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: canonical_cwd,
@@ -49,7 +49,8 @@ impl SessionStore {
/// Build a store from an explicit `--data-dir` flag.
///
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
/// The on-disk layout is `<data_dir>/sessions/<workspace_hash>/`,
/// created lazily on first successful session save.
/// where `<workspace_hash>` is derived from `workspace_root`.
pub fn from_data_dir(
data_dir: impl AsRef<Path>,
@@ -64,7 +65,6 @@ impl SessionStore {
.as_ref()
.join("sessions")
.join(workspace_fingerprint(&canonical_workspace));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: canonical_workspace,
@@ -93,8 +93,19 @@ impl SessionStore {
}
pub fn resolve_reference(&self, reference: &str) -> Result<SessionHandle, SessionControlError> {
self.resolve_reference_excluding(reference, None)
}
/// Resolve a session reference, optionally excluding a session by ID.
/// When the reference is an alias, the excluded session is skipped
/// so /resume latest returns the previous session, not the current one.
pub fn resolve_reference_excluding(
&self,
reference: &str,
exclude_id: Option<&str>,
) -> Result<SessionHandle, SessionControlError> {
if is_session_reference_alias(reference) {
let latest = self.latest_session()?;
let latest = self.latest_session_excluding(exclude_id)?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
@@ -158,12 +169,45 @@ impl SessionStore {
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
if let Some(latest) = self.list_sessions()?.into_iter().next() {
self.latest_session_excluding(None)
}
/// Find the most recent session, optionally excluding a session by ID
/// and skipping sessions with 0 messages. Used by /resume latest to skip
/// the current empty session and find the previous session with actual
/// conversation history.
pub fn latest_session_excluding(
&self,
exclude_id: Option<&str>,
) -> Result<ManagedSessionSummary, SessionControlError> {
let exclude = exclude_id.unwrap_or("");
// First: look in the current workspace's session namespace
if let Some(latest) = self
.list_sessions()?
.into_iter()
.find(|s| s.id != exclude && s.message_count > 0)
{
return Ok(latest);
}
if let Some(latest) = self.scan_global_sessions()?.into_iter().next() {
// Fallback: scan all workspace namespaces under ~/.claw/sessions/
// and project-local .claw/sessions/ so /resume latest finds sessions
// from other workspaces.
if let Some(latest) = self
.scan_global_sessions()?
.into_iter()
.find(|s| s.id != exclude && s.message_count > 0)
{
return Ok(latest);
}
// Distinguish between "no sessions at all" and "sessions exist but
// all are empty" so the user gets a clear signal about what to do.
let has_any_session = self.list_sessions()?.iter().any(|s| s.id != exclude)
|| self.scan_global_sessions()?.iter().any(|s| s.id != exclude);
if has_any_session {
return Err(SessionControlError::Format(format_all_sessions_empty(
&self.sessions_root,
)));
}
Err(SessionControlError::Format(format_no_managed_sessions(
&self.sessions_root,
)))
@@ -204,28 +248,41 @@ impl SessionStore {
&self,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
match self.load_session(reference) {
Ok(loaded) => Ok(loaded),
Err(SessionControlError::WorkspaceMismatch { expected, actual })
if is_session_reference_alias(reference) =>
self.load_session_excluding(reference, None)
}
/// Like `load_session_loose` but also excludes a session by ID.
/// Used by /resume latest to skip the current empty session and find
/// the previous session with actual conversation history.
pub fn load_session_excluding(
&self,
reference: &str,
exclude_id: Option<&str>,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = self.resolve_reference_excluding(reference, exclude_id)?;
let session = Session::load_from_path(&handle.path)?;
// For alias references, allow cross-workspace resume
if is_session_reference_alias(reference) {
if let Err(SessionControlError::WorkspaceMismatch {
expected: _,
actual,
}) = self.validate_loaded_session(&handle.path, &session)
{
let handle = self.resolve_reference(reference)?;
let session = Session::load_from_path(&handle.path)?;
eprintln!(
" Note: resuming session from a different workspace (origin: {})",
actual.display()
);
let _ = expected; // suppress unused warning
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
Err(other) => Err(other),
} else {
self.validate_loaded_session(&handle.path, &session)?;
}
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
pub fn fork_session(
@@ -726,6 +783,16 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
)
}
fn format_all_sessions_empty(sessions_root: &Path) -> String {
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
format!(
"all sessions are empty (0 messages) in .claw/sessions/{fingerprint_dir}/\nThis usually means a fresh `claw` session is running but no messages have been sent yet.\nWait for a response in your other session, then try `--resume {LATEST_SESSION_REFERENCE}` again."
)
}
fn format_legacy_session_missing_workspace_root(
session_path: &Path,
workspace_root: &Path,
@@ -760,14 +827,21 @@ mod tests {
use crate::session::Session;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"runtime-session-control-{}-{nanos}-{counter}",
std::process::id()
))
}
fn persist_session(root: &Path, text: &str) -> Session {
@@ -981,6 +1055,38 @@ mod tests {
}
}
#[test]
fn session_store_from_cwd_is_side_effect_free_until_save() {
// given
let base = temp_dir();
let workspace = base.join("fresh-workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
// when
let store = SessionStore::from_cwd(&workspace).expect("store should build");
// then — resolving the store must not create .claw/session partitions.
assert!(
!workspace.join(".claw").exists(),
"session store construction must not create .claw side effects"
);
assert!(
!store.sessions_dir().exists(),
"session partition should be created lazily on save"
);
let session = persist_session_via_store(&store, "first saved turn");
assert!(
store
.sessions_dir()
.join(format!("{}.jsonl", session.session_id))
.exists(),
"saving a managed session should create the lazy session partition"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_store_from_cwd_isolates_sessions_by_workspace() {
// given
@@ -1181,6 +1287,114 @@ mod tests {
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
// given — create sessions with 0 messages (empty)
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
Session::new()
.with_persistence_path(empty_handle.path.clone())
.save_to_path(&empty_handle.path)
.expect("empty session should save");
// when — latest_session should fail with the "all sessions empty" message
let result = store.latest_session();
assert!(
result.is_err(),
"latest_session should fail when all sessions are empty"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("all sessions are empty"),
"error should mention 'all sessions are empty', got: {err_msg}"
);
assert!(
err_msg.contains("0 messages"),
"error should mention '0 messages', got: {err_msg}"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_excluding_skips_excluded_id_and_returns_previous() {
// given — two sessions WITH messages, newest excluded
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let older = persist_session_via_store(&store, "older work");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer work");
// when — exclude the newest session
let latest = store
.latest_session_excluding(Some(&newer.session_id))
.expect("latest excluding newest should resolve");
// then — the older session wins because the newest is skipped
assert_eq!(
latest.id, older.session_id,
"excluded id must be skipped, returning the previous session"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_filters_out_zero_message_sessions() {
// given — one empty (0-message) session and one non-empty session
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
Session::new()
.with_persistence_path(empty_handle.path.clone())
.save_to_path(&empty_handle.path)
.expect("empty session should save");
wait_for_next_millisecond();
let non_empty = persist_session_via_store(&store, "real conversation");
// when
let latest = store.latest_session().expect("latest should resolve");
// then — the non-empty session wins; the 0-message one is filtered out
assert_eq!(
latest.id, non_empty.session_id,
"0-message session must be filtered out, non-empty session wins"
);
assert!(
latest.message_count > 0,
"resolved session must have messages"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn resolve_reference_excluding_latest_skips_excluded_id() {
// given — two sessions WITH messages
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let older = persist_session_via_store(&store, "older work");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer work");
// when — resolve the "latest" alias while excluding the newest session
let handle = store
.resolve_reference_excluding("latest", Some(&newer.session_id))
.expect("latest alias excluding newest should resolve");
// then — the excluded id is skipped, so the older session resolves
assert_eq!(
handle.id, older.session_id,
"excluded id must be skipped when resolving the latest alias"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_exists_and_delete_are_scoped_to_workspace_store() {
// given

View File

@@ -1,10 +1,9 @@
use std::env;
use std::process::Command;
fn main() {
// Get git SHA (short hash)
let git_sha = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
fn command_output(program: &str, args: &[&str]) -> Option<String> {
Command::new(program)
.args(args)
.output()
.ok()
.and_then(|output| {
@@ -14,11 +13,37 @@ fn main() {
None
}
})
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn main() {
let git_sha =
command_output("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
let git_sha_short = command_output("git", &["rev-parse", "--short=12", "HEAD"])
.or_else(|| git_sha.get(..git_sha.len().min(12)).map(str::to_string))
.unwrap_or_else(|| "unknown".to_string());
let git_dirty = command_output("git", &["status", "--porcelain"])
.map(|status| (!status.trim().is_empty()).to_string())
.unwrap_or_else(|| "false".to_string());
let git_branch = command_output("git", &["branch", "--show-current"])
.unwrap_or_else(|| "unknown".to_string());
let git_commit_date = command_output("git", &["show", "-s", "--format=%cI", "HEAD"])
.unwrap_or_else(|| "unknown".to_string());
let git_commit_timestamp = command_output("git", &["show", "-s", "--format=%ct", "HEAD"])
.unwrap_or_else(|| "unknown".to_string());
let rustc_version =
command_output("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_SHA={git_sha}");
println!("cargo:rustc-env=GIT_SHA_SHORT={git_sha_short}");
println!("cargo:rustc-env=GIT_DIRTY={git_dirty}");
println!("cargo:rustc-env=GIT_BRANCH={git_branch}");
println!("cargo:rustc-env=GIT_COMMIT_DATE={git_commit_date}");
println!("cargo:rustc-env=GIT_COMMIT_TIMESTAMP={git_commit_timestamp}");
println!("cargo:rustc-env=RUSTC_VERSION={rustc_version}");
// TARGET is always set by Cargo during build
// TARGET is always set by Cargo during build.
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=TARGET={target}");
@@ -35,23 +60,12 @@ fn main() {
})
.or_else(|| std::env::var("BUILD_DATE").ok())
.unwrap_or_else(|| {
// Fall back to current date via `date` command
Command::new("date")
.args(["+%Y-%m-%d"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string())
});
println!("cargo:rustc-env=BUILD_DATE={build_date}");
// Rerun if git state changes
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
// Rerun if git state changes. Paths are relative to this package root.
println!("cargo:rerun-if-changed=../../../.git/HEAD");
println!("cargo:rerun-if-changed=../../../.git/refs");
println!("cargo:rerun-if-changed=../../../.git/index");
}

View File

@@ -4,7 +4,14 @@ use std::path::{Path, PathBuf};
const STARTER_CLAW_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
const STARTER_SETTINGS_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
pub(crate) enum InitStatus {
Created,
Updated,
Partial,
Deferred,
Skipped,
}
@@ -24,6 +33,8 @@ impl InitStatus {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Partial => "partial (created missing sub-files)",
Self::Deferred => "deferred (created on first session save)",
Self::Skipped => "skipped (already exists)",
}
}
@@ -36,6 +47,8 @@ impl InitStatus {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Partial => "partial",
Self::Deferred => "deferred",
Self::Skipped => "skipped",
}
}
@@ -123,9 +136,30 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
let mut artifacts = Vec::new();
let claw_dir = cwd.join(".claw");
let claw_dir_status = ensure_dir(&claw_dir)?;
let settings_json = claw_dir.join("settings.json");
let settings_status = write_file_if_missing(&settings_json, STARTER_SETTINGS_JSON)?;
let claw_dir_status =
if claw_dir_status == InitStatus::Skipped && settings_status == InitStatus::Created {
InitStatus::Partial
} else {
claw_dir_status
};
artifacts.push(InitArtifact {
name: ".claw/",
status: ensure_dir(&claw_dir)?,
status: claw_dir_status,
});
artifacts.push(InitArtifact {
name: ".claw/settings.json",
status: settings_status,
});
artifacts.push(InitArtifact {
name: ".claw/sessions/",
status: if claw_dir.join("sessions").is_dir() {
InitStatus::Skipped
} else {
InitStatus::Deferred
},
});
let claw_json = cwd.join(".claw.json");
@@ -414,11 +448,26 @@ mod tests {
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
assert_eq!(
fs::read_to_string(root.join(".claw").join("settings.json"))
.expect("read project settings"),
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
assert!(
!root.join(".claw").join("sessions").exists(),
"sessions directory should be deferred until first session save"
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/"));
@@ -436,14 +485,24 @@ mod tests {
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
fs::create_dir_all(root.join(".claw")).expect("create existing .claw dir");
let first = initialize_repo(&root).expect("first init should succeed");
assert!(first
.render()
.contains("CLAUDE.md skipped (already exists)"));
assert_eq!(
first.artifacts_with_status(InitStatus::Partial),
vec![".claw/".to_string()],
"existing .claw/ should report partial when init creates missing settings.json"
);
assert!(root.join(".claw").join("settings.json").is_file());
let second = initialize_repo(&root).expect("second init should succeed");
let second_rendered = second.render();
assert!(second_rendered.contains(".claw/"));
assert!(second_rendered.contains(".claw/settings.json"));
assert!(second_rendered.contains(".claw/sessions/"));
assert!(second_rendered.contains(".claw.json"));
assert!(second_rendered.contains("skipped (already exists)"));
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
@@ -474,16 +533,22 @@ mod tests {
created_names,
vec![
".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"fresh init should place all four artifacts in created[]"
"fresh init should place created artifacts in created[]"
);
assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts"
);
assert_eq!(
fresh.artifacts_with_status(InitStatus::Deferred),
vec![".claw/sessions/".to_string()],
"fresh init should report session storage as deferred"
);
let second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
@@ -491,27 +556,38 @@ mod tests {
skipped_names,
vec![
".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"idempotent init should place all four artifacts in skipped[]"
"idempotent init should place existing artifacts in skipped[]"
);
assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts"
);
assert_eq!(
second.artifacts_with_status(InitStatus::Deferred),
vec![".claw/sessions/".to_string()],
"idempotent init should keep session storage deferred until first save"
);
// artifact_json_entries() uses the machine-stable `json_tag()` which
// never changes wording (unlike `label()` which says "skipped (already exists)").
let entries = second.artifact_json_entries();
assert_eq!(entries.len(), 4);
assert_eq!(entries.len(), 6);
for entry in &entries {
let name = entry.get("name").and_then(|v| v.as_str()).unwrap();
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
if name == ".claw/sessions/" {
assert_eq!(status, "deferred");
} else {
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
}
}
fs::remove_dir_all(root).expect("cleanup temp dir");

File diff suppressed because it is too large Load Diff

View File

@@ -360,8 +360,8 @@ fn prompt_subcommand_stdin_flag_appends_pipe_context_423() {
}
#[test]
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-json-help");
fn compact_subcommand_json_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-json");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
@@ -372,19 +372,19 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
&workspace,
&config_home,
&home,
&["compact", "--output-format", "json", "--help"],
&["compact", "--output-format", "json"],
Duration::from_secs(2),
);
assert!(
!output.status.success(),
"compact json help should fail non-zero"
"compact json should fail non-zero"
);
// #819/#820/#823: JSON abort envelopes route to stdout
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!(
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
"compact json help should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
"compact json should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value =

View File

@@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::Session;
use serde_json::Value;
use serde_json::{json, Value};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -110,6 +110,9 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
let checks = parsed["check_names"].as_array().expect("check_names");
assert!(checks.iter().any(|check| check == "auth"));
assert!(checks.iter().any(|check| check == "boot preflight"));
assert!(checks.iter().any(|check| check == "memory"));
assert!(checks.iter().any(|check| check == "mcp validation"));
assert!(checks.iter().any(|check| check == "hook validation"));
}
#[test]
@@ -270,29 +273,88 @@ fn version_emits_json_when_requested() {
"version JSON must have action:show (#711)"
);
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
// Provenance fields must be present for binary identification (#507).
// Provenance fields must be present for binary identification (#507/#437).
assert!(
parsed.get("message").is_none(),
"version JSON should not duplicate the text report in legacy message; use human_readable instead: {parsed}"
);
assert!(
parsed["human_readable"]
.as_str()
.is_some_and(|text| text.contains("Claw Code")),
"version JSON should keep text output only in human_readable: {parsed}"
);
let git_sha = parsed["git_sha"]
.as_str()
.expect("git_sha must be the full build commit SHA in version JSON");
assert_eq!(git_sha.len(), 40, "git_sha must not be truncated: {parsed}");
assert!(
git_sha.chars().all(|ch| ch.is_ascii_hexdigit()),
"git_sha must be a hex commit id: {parsed}"
);
let git_sha_short = parsed["git_sha_short"]
.as_str()
.expect("version JSON should expose the short SHA as a separate derived field");
assert!(
git_sha.starts_with(git_sha_short),
"git_sha_short should derive from git_sha: {parsed}"
);
assert!(
parsed["is_dirty"].is_boolean(),
"is_dirty should be boolean: {parsed}"
);
assert!(
parsed["branch"].is_string() || parsed["branch"].is_null(),
"branch should be string|null: {parsed}"
);
assert!(
parsed["commit_date"]
.as_str()
.is_some_and(|date| date != "unknown" && date.contains('T')),
"commit_date should be an ISO-8601 commit timestamp string: {parsed}"
);
assert!(
parsed["commit_timestamp"].as_i64().is_some_and(|ts| ts > 0),
"commit_timestamp should be a positive Unix timestamp: {parsed}"
);
assert!(
parsed["rustc_version"]
.as_str()
.is_some_and(|version| version.starts_with("rustc ")),
"rustc_version should identify the compiler: {parsed}"
);
assert!(
parsed["build_date"].is_string(),
"build_date must be a string in version JSON"
);
assert!(
parsed["executable_path"].is_string(),
"executable_path must be a string in version JSON so callers can identify which binary is running"
parsed["executable_path"].as_str().is_some_and(|path| !path.is_empty()),
"executable_path must be a runtime path string so callers can identify which binary is running"
);
let binary_provenance = parsed["binary_provenance"]
.as_object()
.expect("version JSON must include binary_provenance object (#797)");
.expect("version JSON must include binary_provenance object (#797/#437)");
assert!(matches!(
binary_provenance["status"].as_str(),
Some("known" | "unknown")
));
assert_eq!(binary_provenance["git_sha"], parsed["git_sha"]);
assert_eq!(binary_provenance["target"], parsed["target"]);
assert_eq!(binary_provenance["build_date"], parsed["build_date"]);
assert_eq!(
binary_provenance["executable_path"],
parsed["executable_path"]
);
for key in [
"git_sha",
"git_sha_short",
"is_dirty",
"branch",
"commit_date",
"commit_timestamp",
"rustc_version",
"target",
"build_date",
"executable_path",
] {
assert_eq!(
binary_provenance[key], parsed[key],
"binary_provenance.{key} should mirror top-level version field"
);
}
assert!(
binary_provenance["hint"].is_string() || binary_provenance["hint"].is_null(),
"binary provenance must classify missing/stale lineage with a structured hint field"
@@ -334,6 +396,14 @@ fn version_status_doctor_include_binary_provenance_797() {
version["binary_provenance"]["workspace_match"].is_boolean()
|| version["binary_provenance"]["workspace_match"].is_null()
);
let workspace_git_sha = version["binary_provenance"]["workspace_git_sha"]
.as_str()
.expect("workspace git sha should be a string");
assert_eq!(
workspace_git_sha.len(),
40,
"workspace_git_sha should be a full SHA, not a truncated prefix: {version}"
);
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
@@ -771,14 +841,19 @@ fn acp_guidance_emits_json_when_requested() {
let root = unique_temp_dir("acp-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let acp = assert_json_command(&root, &["--output-format", "json", "acp"]);
// #443: acp serve exits 2 (not implemented) instead of 0
let output = run_claw(&root, &["--output-format", "json", "acp"], &[]);
assert_eq!(
output.status.code(),
Some(2),
"acp should exit 2 (not implemented)"
);
let acp: Value =
serde_json::from_slice(&output.stdout).expect("acp stdout should be valid json");
assert_eq!(acp["kind"], "acp");
assert_eq!(acp["schema_version"], "1.0");
assert_eq!(acp["status"], "unsupported");
assert_eq!(acp["phase"], "discoverability_only");
assert_eq!(acp["status"], "not_implemented");
assert_eq!(acp["supported"], false);
assert_eq!(acp["exit_code"], 0);
assert_eq!(acp["serve_alias_only"], true);
assert_eq!(acp["protocol"]["json_rpc"], false);
assert_eq!(acp["protocol"]["daemon"], false);
assert!(acp["protocol"]["endpoint"].is_null());
@@ -786,12 +861,23 @@ fn acp_guidance_emits_json_when_requested() {
acp["contracts"]["unsupported_invocation_kind"],
"unsupported_acp_invocation"
);
assert_eq!(acp["discoverability_tracking"], "ROADMAP #64a");
assert_eq!(acp["tracking"], "ROADMAP #76 / #3033 / #3004");
// #443: internal tracking IDs removed from public JSON
assert!(
acp.get("discoverability_tracking").is_none(),
"discoverability_tracking should be removed (#443)"
);
assert!(
acp.get("tracking").is_none(),
"tracking should be removed (#443)"
);
assert!(
acp.get("recommended_workflows").is_none(),
"recommended_workflows should be removed (#443)"
);
assert!(acp["message"]
.as_str()
.expect("acp message")
.contains("discoverability alias"));
.contains("not implemented"));
}
#[test]
@@ -1203,6 +1289,138 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
.contains("interactive agent"));
}
#[test]
fn memory_files_load_claude_claw_agents_and_surface_json_438() {
let root = unique_temp_dir("memory-files-438");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&root).expect("temp dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(root.join("CLAUDE.md"), "MARKER-FROM-CLAUDE-MD\n").expect("write CLAUDE.md");
fs::write(root.join("CLAW.md"), "MARKER-FROM-CLAW-MD\n").expect("write CLAW.md");
fs::write(root.join("AGENTS.md"), "MARKER-FROM-AGENTS-MD\n").expect("write AGENTS.md");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(status["workspace"]["memory_file_count"], 3);
let memory_files = status["workspace"]["memory_files"]
.as_array()
.expect("status memory files");
let sources = memory_files
.iter()
.map(|file| file["source"].as_str().expect("memory source"))
.collect::<Vec<_>>();
assert_eq!(sources, vec!["claude_md", "claw_md", "agents_md"]);
assert!(memory_files
.iter()
.all(|file| file["path"].as_str().is_some()));
assert!(memory_files
.iter()
.all(|file| file["chars"].as_u64().unwrap_or(0) > 0));
assert!(memory_files
.iter()
.all(|file| file["contributes"].as_bool() == Some(true)));
assert!(memory_files
.iter()
.all(|file| file["origin"].as_str() == Some("workspace")));
assert!(memory_files
.iter()
.all(|file| file["scope_path"].as_str().is_some()));
assert!(memory_files
.iter()
.all(|file| file["outside_project"].as_bool() == Some(false)));
let prompt =
assert_json_command_with_env(&root, &["--output-format", "json", "system-prompt"], &envs);
let message = prompt["message"].as_str().expect("prompt message");
assert!(message.contains("MARKER-FROM-CLAUDE-MD"));
assert!(message.contains("MARKER-FROM-CLAW-MD"));
assert!(message.contains("MARKER-FROM-AGENTS-MD"));
assert_eq!(prompt["memory_file_count"], 3);
assert_eq!(prompt["memory_files"][1]["source"], "claw_md");
let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs);
let memory = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "memory")
.expect("memory check");
assert_eq!(memory["status"], "ok");
assert_eq!(memory["memory_file_count"], 3);
assert_eq!(memory["memory_files"][2]["source"], "agents_md");
assert!(memory["unloaded_memory_files"]
.as_array()
.expect("unloaded memory files")
.is_empty());
}
#[test]
fn memory_discovery_stops_at_git_root_and_reports_origins_439() {
let root = unique_temp_dir("memory-boundary-439");
let repo = root.join("repo");
let nested = repo.join("subproj").join("deep").join("nest");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&nested).expect("nested dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
Command::new("git")
.args(["init", "-q"])
.current_dir(&repo)
.output()
.expect("git init should launch");
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
fs::write(
repo.join("subproj").join("deep").join("CLAUDE.md"),
"DEEP_CLAUDE",
)
.expect("write deep");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
let status =
assert_json_command_with_env(&nested, &["--output-format", "json", "status"], &envs);
assert_eq!(status["workspace"]["memory_file_count"], 3);
let memory_files = status["workspace"]["memory_files"]
.as_array()
.expect("memory files");
let origins = memory_files
.iter()
.map(|file| file["origin"].as_str().expect("origin"))
.collect::<Vec<_>>();
assert_eq!(origins, vec!["ancestor", "ancestor", "parent_dir"]);
let serialized = serde_json::to_string(memory_files).expect("memory files serialize");
assert!(!serialized.contains("PARENT_CLAUDE"));
assert!(!serialized.contains(root.join("CLAUDE.md").to_str().expect("parent path")));
let prompt = assert_json_command_with_env(
&nested,
&["--output-format", "json", "system-prompt"],
&envs,
);
let message = prompt["message"].as_str().expect("prompt message");
assert!(!message.contains("PARENT_CLAUDE"));
assert!(message.contains("REPO_CLAUDE"));
assert!(message.contains("CHILD_CLAUDE"));
assert!(message.contains("DEEP_CLAUDE"));
assert_eq!(prompt["memory_files"][0]["origin"], "ancestor");
}
#[test]
fn dump_manifests_and_init_emit_json_when_requested() {
let root = unique_temp_dir("manifest-init-json");
@@ -1258,7 +1476,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 8);
assert_eq!(checks.len(), 12);
let check_names = checks
.iter()
.map(|check| {
@@ -1278,9 +1496,13 @@ fn doctor_and_resume_status_emit_json_when_requested() {
check_names,
vec![
"auth",
"base urls",
"config",
"mcp validation",
"hook validation",
"install source",
"workspace",
"memory",
"boot preflight",
"sandbox",
"permissions",
@@ -1518,6 +1740,11 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
);
assert_eq!(version["kind"], "version");
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
assert!(
version.get("message").is_none(),
"resumed /version JSON should not include legacy prose message: {version}"
);
assert!(version["human_readable"].as_str().is_some());
let init = assert_json_command(
&root,
@@ -1616,6 +1843,12 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
assert_eq!(list["action"], "list");
assert_eq!(list["status"], "ok");
assert_eq!(list["configured_servers"], 2);
assert_eq!(list["total_configured"], 2);
assert_eq!(list["valid_count"], 2);
assert_eq!(list["invalid_count"], 0);
assert!(list["invalid_servers"]
.as_array()
.is_some_and(Vec::is_empty));
let servers = list["servers"].as_array().expect("servers array");
let required = servers
.iter()
@@ -1626,6 +1859,8 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
.find(|server| server["name"] == "optional-remote")
.expect("optional remote server should be listed");
assert_eq!(required["required"], true);
assert_eq!(required["valid"], true);
assert_eq!(optional["valid"], true);
assert_eq!(optional["required"], false);
assert_eq!(required["details"]["env_keys"][0], "TOKEN");
assert_eq!(optional["details"]["header_keys"][0], "Authorization");
@@ -1644,6 +1879,10 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
assert_eq!(show["action"], "show");
assert_eq!(show["status"], "ok");
assert_eq!(show["server"]["required"], false);
assert_eq!(show["server"]["valid"], true);
assert_eq!(show["total_configured"], 2);
assert_eq!(show["valid_count"], 2);
assert_eq!(show["invalid_count"], 0);
assert_eq!(show["server"]["details"]["header_keys"][0], "Authorization");
let show_text = serde_json::to_string(&show).expect("mcp show json should serialize");
assert!(!show_text.contains("secret-header-value"));
@@ -1653,18 +1892,29 @@ fn mcp_json_reports_required_optional_and_redacts_secret_values() {
#[test]
fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
let root = unique_temp_dir("mcp-degraded-vs-failed");
let workspace = root.join("workspace");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&root).expect("workspace should exist");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(
root.join(".claw.json"),
workspace.join(".claw.json"),
r#"{
"mcpServers": {
"valid-server": {
"command": "/bin/echo",
"args": ["hello"]
},
"missing-command": {
"args": ["arg-only-no-command"],
"required": true
},
"empty-command": {
"command": ""
},
"wrong-type-command": {
"command": 42
}
}
}"#,
@@ -1678,18 +1928,56 @@ fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
("HOME", home.to_str().expect("home")),
];
let degraded = assert_json_command_with_env(&root, &["--output-format", "json", "mcp"], &envs);
let degraded =
assert_json_command_with_env(&workspace, &["--output-format", "json", "mcp"], &envs);
assert_eq!(degraded["kind"], "mcp");
assert_eq!(degraded["action"], "list");
assert_eq!(degraded["status"], "degraded");
assert!(degraded["config_load_error"]
assert!(degraded["config_load_error"].is_null());
assert_eq!(degraded["configured_servers"], 1);
assert_eq!(degraded["total_configured"], 4);
assert_eq!(degraded["valid_count"], 1);
assert_eq!(degraded["invalid_count"], 3);
assert_eq!(degraded["servers"][0]["name"], "valid-server");
assert_eq!(degraded["servers"][0]["valid"], true);
assert_eq!(degraded["invalid_servers"][0]["name"], "empty-command");
assert_eq!(degraded["invalid_servers"][0]["error_field"], "command");
assert!(degraded["invalid_servers"][0]["reason"]
.as_str()
.is_some_and(|error| error.contains("mcpServers.missing-command")));
assert_eq!(degraded["configured_servers"], 0);
assert!(degraded["servers"].as_array().expect("servers").is_empty());
.is_some_and(|error| error.contains("non-empty string")));
assert_eq!(degraded["invalid_servers"][1]["name"], "missing-command");
assert_eq!(degraded["invalid_servers"][1]["error_field"], "command");
assert!(degraded["invalid_servers"][1]["reason"]
.as_str()
.is_some_and(|error| error.contains("missing string field command")));
assert_eq!(degraded["invalid_servers"][2]["name"], "wrong-type-command");
assert_eq!(degraded["invalid_servers"][2]["error_field"], "command");
let status =
assert_json_command_with_env(&workspace, &["--output-format", "json", "status"], &envs);
assert_eq!(status["status"], "degraded");
assert!(status["config_load_error"].is_null());
assert_eq!(status["mcp_validation"]["total_configured"], 4);
assert_eq!(status["mcp_validation"]["valid_count"], 1);
assert_eq!(status["mcp_validation"]["invalid_count"], 3);
let doctor =
assert_json_command_with_env(&workspace, &["--output-format", "json", "doctor"], &envs);
let mcp_validation = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "mcp validation")
.expect("mcp validation check");
assert_eq!(mcp_validation["status"], "warn");
assert_eq!(mcp_validation["invalid_count"], 3);
assert_eq!(
mcp_validation["invalid_servers"][0]["name"],
"empty-command"
);
let failed_output = run_claw(
&root,
&workspace,
&["--output-format", "json", "mcp", "list", "extra"],
&envs,
);
@@ -1794,7 +2082,7 @@ fn local_json_surfaces_have_non_empty_action_contract_714() {
&git_workspace,
strings(&["--output-format", "json", "diff"]),
),
(&workspace, strings(&["--output-format", "json", "acp"])),
// #443: ACP exits 2 (not implemented); tested separately in acp_guidance_emits_json_when_requested
(&workspace, strings(&["--output-format", "json", "config"])),
(
&workspace,
@@ -3077,7 +3365,7 @@ fn config_unsupported_section_json_hint_741() {
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
for section in &["list", "show", "bogus", "help"] {
for section in &["list", "show", "bogus"] {
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "config", section])
@@ -3115,6 +3403,36 @@ fn config_unsupported_section_json_hint_741() {
}
}
#[test]
fn config_help_returns_structured_section_list_344() {
// #344: /config help should return a structured section list, not an error
use std::process::Command;
let root = unique_temp_dir("config-help");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "config", "help"])
.output()
.expect("claw config help should run");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("config help should emit valid JSON");
assert_eq!(parsed["kind"], "config", "config help kind must be config");
assert_eq!(
parsed["status"], "ok",
"config help must return status:ok (#344)"
);
assert_eq!(
parsed["section"], "help",
"config help section must be help"
);
let sections = parsed["available_sections"]
.as_array()
.expect("config help must have available_sections array");
assert!(!sections.is_empty(), "available_sections must not be empty");
}
#[test]
fn export_json_has_kind_702() {
// #458/#702: `claw export --output-format json` must emit kind:export.
@@ -3597,7 +3915,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
};
let parsed: serde_json::Value =
serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON");
assert_eq!(parsed["error_kind"], "unknown_mcp_action");
assert_eq!(parsed["error_kind"], "unsupported_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)");
}
@@ -3877,8 +4195,8 @@ fn acp_unsupported_invocation_has_hint_782() {
.expect("hint must be non-null (#782)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("discoverability") || hint.contains("ROADMAP"),
"hint should explain the discoverability-only status, got: {hint:?}"
hint.contains("not implemented") || hint.contains("unsupported"),
"hint should explain the not-implemented status, got: {hint:?}"
);
}
@@ -3928,6 +4246,41 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
hint.contains("CLAUDE.md") || hint.contains("doctor"),
"fresh-init hint should mention CLAUDE.md or doctor, got: {hint:?}"
);
assert_eq!(
parsed["created"],
json!([
".claw/",
".claw/settings.json",
".claw.json",
".gitignore",
"CLAUDE.md"
]),
"fresh init should materialize .claw/settings.json and safe .claw.json"
);
assert_eq!(
parsed["deferred"],
json!([".claw/sessions/"]),
"session storage should be reported as deferred until first save"
);
assert_eq!(parsed["partial"], json!([]));
let claw_json = fs::read_to_string(root.join(".claw.json")).expect("read .claw.json");
assert!(
claw_json.contains("\"defaultMode\": \"acceptEdits\""),
"init must not scaffold dontAsk in .claw.json: {claw_json}"
);
assert!(
!claw_json.contains("dontAsk"),
"init must not scaffold unsafe dontAsk permission mode: {claw_json}"
);
let settings_json = root.join(".claw").join("settings.json");
assert!(
settings_json.is_file(),
"init should template .claw/settings.json"
);
assert!(
!root.join(".claw").join("sessions").exists(),
"sessions directory should remain deferred until first save"
);
// Idempotent re-init — already_initialized should be true
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
@@ -3954,6 +4307,43 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
hint2.contains("already") || hint2.contains("doctor"),
"re-init hint should acknowledge workspace exists, got: {hint2:?}"
);
let existing_claw_root = unique_temp_dir("init-existing-claw-436");
fs::create_dir_all(existing_claw_root.join(".claw")).expect("existing .claw dir");
let partial_output = run_claw(
&existing_claw_root,
&["--output-format", "json", "init"],
&[],
);
assert!(
partial_output.status.success(),
"init with existing .claw should succeed"
);
let partial_stdout = String::from_utf8_lossy(&partial_output.stdout);
let partial: serde_json::Value =
serde_json::from_str(partial_stdout.trim()).expect("partial init should emit valid JSON");
assert_eq!(
partial["partial"],
json!([".claw/"]),
"existing .claw with newly-created settings should report partial .claw/"
);
assert_eq!(
partial["created"],
json!([
".claw/settings.json",
".claw.json",
".gitignore",
"CLAUDE.md"
]),
"init should still create missing sub-files when .claw already exists"
);
assert!(
existing_claw_root
.join(".claw")
.join("settings.json")
.is_file(),
"existing .claw must receive missing settings template"
);
}
#[test]
@@ -5424,6 +5814,54 @@ fn multi_word_unknown_subcommand_json_emits_command_not_found_826() {
);
}
#[test]
fn compact_flag_missing_argument_and_shorthand_prompt_contract_435() {
let root = unique_temp_dir("compact-flag-435");
let config_home = root.join("config-home");
let home = root.join("home");
std::fs::create_dir_all(&root).expect("create temp dir");
std::fs::create_dir_all(&config_home).expect("create config home");
std::fs::create_dir_all(&home).expect("create home");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("config home utf8"),
),
("HOME", home.to_str().expect("home utf8")),
("ANTHROPIC_API_KEY", ""),
("ANTHROPIC_AUTH_TOKEN", ""),
("OPENAI_API_KEY", ""),
];
let missing = run_claw(&root, &["--output-format", "json", "--compact"], &envs);
assert_eq!(missing.status.code(), Some(1));
assert!(
missing.stderr.is_empty(),
"compact missing-argument JSON should keep stderr empty: {}",
String::from_utf8_lossy(&missing.stderr)
);
let missing_json = parse_json_stdout(&missing, "compact missing argument");
assert_eq!(missing_json["error_kind"], "missing_argument");
assert_eq!(missing_json["argument"], "prompt or subcommand");
let prompt = run_claw(
&root,
&["--output-format", "json", "--compact", "hello"],
&envs,
);
assert_eq!(prompt.status.code(), Some(1));
assert!(
prompt.stderr.is_empty(),
"compact prompt JSON should keep stderr empty: {}",
String::from_utf8_lossy(&prompt.stderr)
);
let prompt_json = parse_json_stdout(&prompt, "compact shorthand prompt");
assert_eq!(
prompt_json["error_kind"], "missing_credentials",
"--compact hello should stay on the prompt/provider path, not command_not_found: {prompt_json}"
);
}
// #827: direct /unknown-slash-command must emit typed error_kind, not "unknown"
// Uses the direct-slash CLI path (no session load needed; reproducible on CI).
#[test]

View File

@@ -222,6 +222,73 @@ fn resume_latest_restores_the_most_recent_managed_session() {
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
}
#[test]
fn resume_latest_missing_session_fails_without_creating_session_dirs_435() {
// given
let temp_dir = unique_temp_dir("resume-latest-missing-435");
let project_dir = temp_dir.join("project");
let config_home = temp_dir.join("config-home");
let home = temp_dir.join("home");
fs::create_dir_all(&project_dir).expect("project dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
("ANTHROPIC_API_KEY", ""),
("ANTHROPIC_AUTH_TOKEN", ""),
("OPENAI_API_KEY", ""),
];
// when — both text and JSON resume failures should be non-zero and read-only.
let text = run_claw_with_env(&project_dir, &["--resume", "latest"], &envs);
let json = run_claw_with_env(
&project_dir,
&["--output-format", "json", "--resume", "latest"],
&envs,
);
// then
assert_eq!(
text.status.code(),
Some(1),
"text resume failure must be non-zero"
);
assert!(
text.stdout.is_empty(),
"text resume failure should not claim success on stdout: {}",
String::from_utf8_lossy(&text.stdout)
);
let text_stderr = String::from_utf8_lossy(&text.stderr);
assert!(
text_stderr.contains("no managed sessions found"),
"text failure should explain missing sessions: {text_stderr}"
);
assert_eq!(
json.status.code(),
Some(1),
"JSON resume failure must be non-zero"
);
assert!(
json.stderr.is_empty(),
"JSON resume failure should keep stderr empty: {}",
String::from_utf8_lossy(&json.stderr)
);
let parsed: Value = serde_json::from_slice(&json.stdout)
.expect("JSON resume failure should emit JSON to stdout");
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["action"], "restore");
assert_eq!(parsed["error_kind"], "no_managed_sessions");
assert!(
!project_dir.join(".claw").exists(),
"failed resume must not create .claw/session directories"
);
}
#[test]
fn resumed_status_command_emits_structured_json_when_requested() {
// given
@@ -268,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert_eq!(parsed["kind"], "status");
// model is null in resume mode (not known without --model flag)
assert!(parsed["model"].is_null());
assert_eq!(parsed["permission_mode"], "danger-full-access");
assert_eq!(parsed["permission_mode"], "workspace-write");
assert_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number());
assert!(parsed["workspace"]["cwd"].as_str().is_some());
@@ -396,6 +463,9 @@ fn resumed_version_command_emits_structured_json() {
assert!(parsed["version"].as_str().is_some());
assert!(parsed["git_sha"].as_str().is_some());
assert!(parsed["target"].as_str().is_some());
assert!(parsed["git_sha_short"].as_str().is_some());
assert!(parsed.get("message").is_none());
assert!(parsed["human_readable"].as_str().is_some());
}
#[test]
@@ -460,8 +530,9 @@ fn resumed_help_command_emits_structured_json() {
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "help");
assert!(parsed["text"].as_str().is_some());
let text = parsed["text"].as_str().unwrap();
// #338: resume help now uses 'message' field for parity with top-level help
assert!(parsed["message"].as_str().is_some());
let text = parsed["message"].as_str().unwrap();
assert!(text.contains("/status"), "help text should list /status");
}

View File

@@ -2701,6 +2701,20 @@ fn is_within_workspace(path: &str) -> bool {
let path = PathBuf::from(trimmed);
// Reject any parent-directory traversal. Callers never need `..` to refer
// to files inside the workspace, and `..` defeats both checks below: the
// relative branch only inspects the leading component, and the absolute
// branch's `canonicalize()` silently falls back to the literal `..` path
// when the target does not exist yet (e.g. a file about to be created).
// Returning false here is the safe direction: it classifies the command as
// requiring full-access permission rather than workspace-write.
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return false;
}
// If path is absolute, check if it starts with CWD
if path.is_absolute() {
if let Ok(cwd) = std::env::current_dir() {
@@ -2718,6 +2732,26 @@ fn run_powershell(input: PowerShellInput) -> Result<String, String> {
to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
}
#[cfg(test)]
mod workspace_traversal_guard_tests {
use super::is_within_workspace;
#[test]
fn rejects_parent_traversal_components() {
// Leading and embedded `..` must both be rejected (was previously a hole
// because only the leading component was inspected).
assert!(!is_within_workspace("../secrets"));
assert!(!is_within_workspace("src/../../etc/passwd"));
assert!(!is_within_workspace("a/b/../../../etc/crontab"));
}
#[test]
fn allows_plain_relative_paths() {
assert!(is_within_workspace("src/main.rs"));
assert!(is_within_workspace("Cargo.toml"));
}
}
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
}