Compare commits

...

147 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
bellman
b5bead9028 fix: recover CLI parser CI
Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-04 13:25:15 +09:00
bellman
41678eb097 fix: type output format selection 2026-06-04 12:47:24 +09:00
bellman
ecd3e4ceb9 fix: type allowed tools validation 2026-06-04 12:01:58 +09:00
bellman
22fdaeae2c fix: keep skills lifecycle local 2026-06-04 03:58:35 +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
bellman
4522490bd5 fix: make dump-manifests self-contained 2026-06-04 02:46:44 +09:00
bellman
cd58c054ca fix: add global cwd override 2026-06-04 02:20:09 +09:00
bellman
94579eace5 fix: default to workspace-write permissions 2026-06-04 01:51:21 +09:00
bellman
2ab2f44e1d fix: keep session help local 2026-06-04 00:50:17 +09:00
bellman
fa35018769 fix: validate env model selection 2026-06-04 00:30:13 +09:00
bellman
94be902ce1 fix: attribute config precedence in JSON 2026-06-03 23:47:27 +09:00
bellman
bcc5bfde9c fix: route local OpenAI-compatible models 2026-06-03 23:16:46 +09:00
bellman
9522674c87 fix: read prompt subcommand input from stdin 2026-06-03 22:39:16 +09:00
bellman
c91a3062d5 fix: normalize Anthropic model routing 2026-06-03 22:20:23 +09:00
bellman
54d785d0c0 fix: preserve DeepSeek V4 thinking history 2026-06-03 21:53:54 +09:00
bellman
36218ac1b1 fix: report config file load statuses 2026-06-03 21:46:47 +09:00
bellman
6388a2ba3f fix: parse object-style hook config 2026-06-03 21:23:00 +09:00
bellman
9c8375da99 feat: import project instruction rules 2026-06-03 21:01:48 +09:00
Heo, Sung
0cef5390f7 fix: resolve clippy pedantic warnings
Apply the bounded clippy pedantic cleanup from PR #3009.
2026-06-03 20:39:05 +09:00
bellman
1bd18be372 feat: add GitShow output formats 2026-06-03 20:28:12 +09:00
bellman
d07664b44c fix: keep hooks clean and close bash stdin 2026-06-03 20:20:04 +09:00
bellman
ce116d9dfa fix: expose binary provenance in local JSON 2026-06-03 20:03:39 +09:00
bellman
372ec09c47 test: cover roadmap helper missing path 2026-06-03 19:31:45 +09:00
bellman
78f446f68e test: add argv-safe dogfood probes 2026-06-03 19:26:55 +09:00
bellman
55da189315 fix: keep JSON control surfaces local 2026-06-03 19:12:20 +09:00
bellman
e752b05425 fix: load common instruction files and typed unknown commands 2026-06-03 18:54:36 +09:00
bellman
0c83a26dc7 test: cover resumed unknown slash command 2026-06-03 18:40:37 +09:00
bellman
286638fa04 docs: close ROADMAP 828 approval slash evidence 2026-06-03 18:29:16 +09:00
bellman
47d6c3d5d3 docs: close ROADMAP 829 interactive hint evidence 2026-06-03 18:26:37 +09:00
bellman
f529fb0e55 fix: classify mcp show missing server argument 2026-06-03 18:22:23 +09: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
41 changed files with 11778 additions and 1911 deletions

View File

@@ -13,7 +13,7 @@ cd "$repo_root"
if [[ -x scripts/roadmap-check-ids.sh ]]; then if [[ -x scripts/roadmap-check-ids.sh ]]; then
echo "pre-push: scripts/roadmap-check-ids.sh" >&2 echo "pre-push: scripts/roadmap-check-ids.sh" >&2
scripts/roadmap-check-ids.sh scripts/roadmap-check-ids.sh >&2
fi fi
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ archive/
# Claw Code local artifacts # Claw Code local artifacts
.claw/settings.local.json .claw/settings.local.json
.claw/sessions/ .claw/sessions/
.claw/rules.local/
.clawhip/ .clawhip/
status-help.txt status-help.txt
# Legacy Python port session scratch artifacts # Legacy Python port session scratch artifacts

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-openagent](https://github.com/code-yeongyu/oh-my-openagent)
- [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) - [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode)
- [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex) - [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) - [UltraWorkers Discord](https://discord.gg/5TUQKqFWd)
## Ownership / affiliation disclaimer ## Ownership / affiliation disclaimer

File diff suppressed because one or more lines are too long

156
USAGE.md
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. **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 ### 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 ```bash
cd /path/to/your/repo cd /path/to/your/repo
./target/debug/claw init ./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: JSON mode for scripting:
```bash ```bash
./target/debug/claw init --output-format json ./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 ### Interactive REPL
@@ -86,6 +87,12 @@ cd rust
./target/debug/claw prompt "summarize this repository" ./target/debug/claw prompt "summarize this repository"
``` ```
Pipe prompt text through stdin when automation already produces the prompt body:
```bash
printf 'summarize this repository\n' | ./target/debug/claw prompt --output-format json
```
### Shorthand prompt mode ### Shorthand prompt mode
```bash ```bash
@@ -93,6 +100,12 @@ cd rust
./target/debug/claw "explain rust/crates/runtime/src/lib.rs" ./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
``` ```
Use the POSIX `--` end-of-flags separator when the shorthand prompt itself begins with `-` or `--`:
```bash
./target/debug/claw -- "-summarize this dash-prefixed text"
```
### JSON output for scripting ### JSON output for scripting
```bash ```bash
@@ -187,17 +200,24 @@ cd rust
./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml" ./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml"
./target/debug/claw --permission-mode workspace-write prompt "update README.md" ./target/debug/claw --permission-mode workspace-write prompt "update README.md"
./target/debug/claw --allowedTools read,glob "inspect the runtime crate" ./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
./target/debug/claw --cwd ../other-workspace status --output-format json
``` ```
Supported permission modes: Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH` are accepted before any subcommand. They are validated before command dispatch and take precedence over the process `$PWD`; invalid paths return typed `invalid_cwd` JSON errors in JSON mode.
- `read-only` `--allowedTools` accepts canonical snake_case tool names (for example `read_file`, `glob_search`, `web_fetch`) plus documented aliases such as `read`, `glob`, `Read`, and `WebFetch`. `claw status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and invalid values return typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. A missing value before a subcommand or another flag returns `missing_argument` with `argument:"--allowedTools"`.
- `workspace-write`
- `danger-full-access` `--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`.
Supported permission modes (default: `workspace-write`):
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
- `workspace-write` is the safe default. It allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
- `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in.
Model aliases currently supported by the CLI: Model aliases currently supported by the CLI:
- `opus``claude-opus-4-6` - `opus``claude-opus-4-7`
- `sonnet``claude-sonnet-4-6` - `sonnet``claude-sonnet-4-6`
- `haiku``claude-haiku-4-5-20251213` - `haiku``claude-haiku-4-5-20251213`
@@ -225,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) | | `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 | | 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) | | 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. **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.
@@ -285,13 +306,25 @@ cd rust
### Ollama ### Ollama
```bash ```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1" export OLLAMA_HOST="http://127.0.0.1:11434"
unset OPENAI_API_KEY
cd rust cd rust
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence" ./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
``` ```
`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 OLLAMA_HOST="http://127.0.0.1:11434"
cd rust
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"
```
If the local server exposes a slash-containing model ID, prefix it with `local/` so Claw selects the OpenAI-compatible transport while sending the remainder verbatim on the wire: `--model "local/Qwen/Qwen3.6-27B-FP8"`.
### OpenRouter ### OpenRouter
```bash ```bash
@@ -334,7 +367,7 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service. The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
**Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API, `openai/` is a routing prefix and is stripped before the request hits the wire. For a custom `OPENAI_BASE_URL`, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects. **Model-name prefix routing:** If a model name starts with `openai/`, `local/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API and local/private OpenAI-compatible endpoints, `openai/` is a routing prefix and is stripped before the request hits the wire. For non-local custom `OPENAI_BASE_URL` gateways, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects. The `local/` prefix is an explicit escape hatch for local slash-containing model IDs: it is stripped while the rest of the model ID is sent verbatim.
### Tested models and aliases ### Tested models and aliases
@@ -342,17 +375,19 @@ These are the models registered in the built-in alias table with known token lim
| Alias | Resolved model name | Provider | Max output tokens | Context window | | Alias | Resolved model name | Provider | Max output tokens | Context window |
|---|---|---|---|---| |---|---|---|---|---|
| `opus` | `claude-opus-4-6` | Anthropic | 32 000 | 200 000 | | `opus` | `claude-opus-4-7` | Anthropic | 32 000 | 200 000 |
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 | | `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 | | `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 | | `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 | | `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
| `grok-2` | `grok-2` | xAI | — | — | | `grok-2` | `grok-2` | xAI | — | — |
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 | | `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
| `qwen-max` | `qwen-max` | DashScope | 8 192 | 131 072 |
| `qwen-plus` | `qwen-plus` | DashScope | 8 192 | 131 072 |
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 | | `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 | | `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |
Any model name that does not match an alias is passed through verbatim after provider routing is resolved. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini` with a custom `OPENAI_BASE_URL`), Ollama tags (`llama3.2`), or full Anthropic model IDs (`claude-sonnet-4-20250514`). Any model name that does not match an alias is passed through verbatim after provider routing is resolved. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini` with a custom `OPENAI_BASE_URL`), Ollama tags (`llama3.2` or `qwen2.5-coder:7b`), slash-containing local IDs (`local/Qwen/Qwen3.6-27B-FP8`), or full Anthropic model IDs (`claude-sonnet-4-20250514`).
### User-defined aliases ### User-defined aliases
@@ -362,7 +397,7 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
{ {
"aliases": { "aliases": {
"fast": "claude-haiku-4-5-20251213", "fast": "claude-haiku-4-5-20251213",
"smart": "claude-opus-4-6", "smart": "claude-opus-4-7",
"cheap": "grok-3-mini" "cheap": "grok-3-mini"
} }
} }
@@ -370,13 +405,15 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
Local project settings override user-level settings. Aliases resolve through the built-in table, so `"fast": "haiku"` also works. Local project settings override user-level settings. Aliases resolve through the built-in table, so `"fast": "haiku"` also works.
Model selection precedence is CLI flag, environment, config, then default. The environment model slot accepts `CLAW_MODEL`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_MODEL` in that order; aliases from those variables are resolved and validated before provider startup. `claw --output-format json status` exposes `model_raw`, `model_alias_resolved_to`, and `model_env_var` so automation can see the winning value.
### How provider detection works ### How provider detection works
1. If the resolved model name starts with `claude` → Anthropic. 1. If the resolved model name starts with `claude` → Anthropic.
2. If it starts with `grok` → xAI. 2. If it starts with `grok` → xAI.
3. If it starts with `openai/` or `gpt-` → OpenAI-compatible. 3. If it starts with `openai/`, `local/`, or `gpt-` → OpenAI-compatible.
4. If it starts with `qwen/`, `qwen-`, `kimi/`, or `kimi-` → DashScope-compatible OpenAI wire format. 4. If it starts with `qwen/`, `qwen-`, `kimi/`, or `kimi-` → DashScope-compatible OpenAI wire format.
5. If `OPENAI_BASE_URL` and `OPENAI_API_KEY` are set, unknown model names route to the OpenAI-compatible client for local/gateway servers. 5. If `OPENAI_BASE_URL` is set, local-looking unknown model names such as `llama3.2` or `qwen2.5-coder:7b` route to the OpenAI-compatible client for local/gateway servers.
6. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers. 6. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers.
7. If nothing matches, it defaults to Anthropic. 7. If nothing matches, it defaults to Anthropic.
@@ -417,6 +454,9 @@ The name "codex" appears in the Claw Code ecosystem but it does **not** refer to
export HTTPS_PROXY="http://proxy.corp.example:3128" export HTTPS_PROXY="http://proxy.corp.example:3128"
export HTTP_PROXY="http://proxy.corp.example:3128" export HTTP_PROXY="http://proxy.corp.example:3128"
export NO_PROXY="localhost,127.0.0.1,.corp.example" export NO_PROXY="localhost,127.0.0.1,.corp.example"
export CLAW_OUTPUT_FORMAT="json" # default non-interactive output format; flags override it
export CLAW_LOG="debug" # claw-specific log level selector surfaced by help/doctor
export RUST_LOG="claw=debug" # Rust logging convention surfaced by help/doctor
cd rust cd rust
./target/debug/claw prompt "hello via the corporate proxy" ./target/debug/claw prompt "hello via the corporate proxy"
@@ -452,11 +492,12 @@ let client = build_http_client_with(&config).expect("proxy client");
## Skills ## Skills
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it: Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it. `skills install`, `skills uninstall`, and `agents create` are local filesystem lifecycle commands; they do not require provider credentials.
```text ```text
/skills install /absolute/path/to/my-skill /skills install /absolute/path/to/my-skill
/skills list /skills list
/skills uninstall my-skill
/skills my-skill /skills my-skill
``` ```
@@ -469,6 +510,7 @@ cd rust
./target/debug/claw status ./target/debug/claw status
./target/debug/claw sandbox ./target/debug/claw sandbox
./target/debug/claw agents ./target/debug/claw agents
./target/debug/claw agents create my-agent
./target/debug/claw mcp ./target/debug/claw mcp
./target/debug/claw skills ./target/debug/claw skills
./target/debug/claw system-prompt --cwd .. --date 2026-04-04 ./target/debug/claw system-prompt --cwd .. --date 2026-04-04
@@ -488,6 +530,7 @@ git clone https://github.com/Xquik-dev/tweetclaw
cd claw-code/rust cd claw-code/rust
./target/debug/claw skills install ../../tweetclaw/skills/tweetclaw ./target/debug/claw skills install ../../tweetclaw/skills/tweetclaw
./target/debug/claw skills show tweetclaw ./target/debug/claw skills show tweetclaw
./target/debug/claw skills uninstall tweetclaw
``` ```
TweetClaw gives `claw` users a local skill guide for OpenClaw/Xquik workflows TweetClaw gives `claw` users a local skill guide for OpenClaw/Xquik workflows
@@ -495,6 +538,15 @@ such as tweet search, reply search, follower export, monitors, webhooks, and
approval-gated posting. Configure any Xquik credentials outside the prompt and approval-gated posting. Configure any Xquik credentials outside the prompt and
avoid pasting API keys into chat. avoid pasting API keys into chat.
## Author a local agent
`claw agents create <name>` scaffolds a local `.claw/agents/<name>.toml` file for the current workspace. The scaffold is intentionally small so you can edit the description, model, and reasoning effort before listing or invoking agents:
```bash
./target/debug/claw agents create release-checker
./target/debug/claw agents list
```
## Session management ## Session management
REPL turns are persisted under `.claw/sessions/` in the current workspace. REPL turns are persisted under `.claw/sessions/` in the current workspace.
@@ -517,6 +569,74 @@ Runtime config is loaded in this order, with later entries overriding earlier on
4. `<repo>/.claw/settings.json` 4. `<repo>/.claw/settings.json`
5. `<repo>/.claw/settings.local.json` 5. `<repo>/.claw/settings.local.json`
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:
```json
{
"hooks": {
"PreToolUse": [
"echo legacy hook",
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "scripts/audit-bash.sh" }
]
}
]
}
}
```
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`, `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
{
"rulesImport": "none"
}
```
Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks.
## Mock parity harness ## Mock parity harness
The workspace includes a deterministic Anthropic-compatible mock service and parity harness. The workspace includes a deterministic Anthropic-compatible mock service and parity harness.

View File

@@ -148,12 +148,12 @@ pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/com
**Affected models:** Slash-containing model IDs routed through the OpenAI-compatible provider, especially custom gateways configured with `OPENAI_BASE_URL` such as OpenRouter, local routers, or other `/v1/chat/completions` services. **Affected models:** Slash-containing model IDs routed through the OpenAI-compatible provider, especially custom gateways configured with `OPENAI_BASE_URL` such as OpenRouter, local routers, or other `/v1/chat/completions` services.
**Behavior:** **Behavior:**
- The default OpenAI API treats `openai/` as a routing prefix and sends the bare model name on the wire. - The default OpenAI API and local/private OpenAI-compatible base URLs treat `openai/` as a routing prefix and send the bare model name on the wire.
- Custom OpenAI-compatible base URLs preserve slash-containing slugs such as `openai/gpt-4.1-mini` so the gateway receives the exact model ID it expects. - Non-local custom OpenAI-compatible base URLs preserve slash-containing slugs such as `openai/gpt-4.1-mini` so gateways like OpenRouter receive the exact model ID they expect. Local slash-containing model IDs can use `local/`, which strips only that escape-hatch prefix and sends the remainder verbatim.
- `MessageRequest::extra_body` passes through custom request JSON after core fields are populated. This supports provider-specific options such as `web_search_options` and `parallel_tool_calls`. - `MessageRequest::extra_body` passes through custom request JSON after core fields are populated. This supports provider-specific options such as `web_search_options` and `parallel_tool_calls`.
- Protected core fields (`model`, `messages`, `stream`, `tools`, `tool_choice`, `max_tokens`, `max_completion_tokens`) cannot be overridden through `extra_body`. - Protected core fields (`model`, `messages`, `stream`, `tools`, `tool_choice`, `max_tokens`, `max_completion_tokens`) cannot be overridden through `extra_body`.
**Testing:** See `custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params` in `openai_compat_integration.rs` and `extra_body_params_are_passed_through_without_overriding_core_fields` in `openai_compat.rs`. **Testing:** See `custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params` in `openai_compat_integration.rs`, `wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways`, `local_routing_prefix_strips_only_escape_hatch`, and `extra_body_params_are_passed_through_without_overriding_core_fields` in `openai_compat.rs`.
## Implementation Details ## Implementation Details

View File

@@ -13,7 +13,7 @@ If you need the most polished daily-driver experience for a specific non-Claude
## OpenAI-compatible routing basics ## OpenAI-compatible routing basics
Set `OPENAI_BASE_URL` to the servers `/v1` endpoint and set `OPENAI_API_KEY` to either the required token or a harmless placeholder for local servers that expect an Authorization header. The model name must match what the server exposes. Set `OPENAI_BASE_URL` to the servers `/v1` endpoint and set `OPENAI_API_KEY` to either the required token or a harmless placeholder for local servers that expect an Authorization header. Authless local/private OpenAI-compatible servers can leave `OPENAI_API_KEY` unset. The model name must match what the server exposes.
```bash ```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1" export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
@@ -24,8 +24,8 @@ claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
Routing notes: Routing notes:
- Use the `openai/` prefix for OpenAI-compatible gateways when you need prefix routing to win over ambient Anthropic credentials, for example `--model "openai/gpt-4.1-mini"` with OpenRouter. - Use the `openai/` prefix for OpenAI-compatible gateways when you need prefix routing to win over ambient Anthropic credentials, for example `--model "openai/gpt-4.1-mini"` with OpenRouter.
- For local servers, prefer the exact model ID reported by the server (`qwen3:latest`, `llama3.2`, `Qwen/Qwen2.5-Coder-7B-Instruct`, etc.). If your local gateway exposes slash-containing IDs, use that exact slug. - For local servers, prefer the exact model ID reported by the server (`qwen3:latest`, `llama3.2`, etc.). If your local gateway exposes slash-containing IDs, prefix the exact slug with `local/` so Claw routes through OpenAI-compatible transport while sending the rest verbatim, for example `--model "local/Qwen/Qwen2.5-Coder-7B-Instruct"`.
- If you have multiple provider keys in your environment, remove unrelated keys while smoke-testing a local route or choose a model prefix that unambiguously selects the intended provider. - If you have multiple provider keys in your environment, `OPENAI_BASE_URL` plus local-looking tags such as `llama3.2` or `qwen2.5-coder:7b` selects the local OpenAI-compatible route; use `local/` for slash-containing local IDs.
- Tool workflows need model/server support for OpenAI-compatible tool calls. Plain prompt smoke tests can pass even when slash/tool workflows still fail because the server returns an incompatible tool-call shape. - Tool workflows need model/server support for OpenAI-compatible tool calls. Plain prompt smoke tests can pass even when slash/tool workflows still fail because the server returns an incompatible tool-call shape.
## Raw `/v1/chat/completions` smoke test ## Raw `/v1/chat/completions` smoke test
@@ -57,12 +57,13 @@ ollama serve
In another shell: In another shell:
```bash ```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1" export OLLAMA_HOST="http://127.0.0.1:11434"
export OPENAI_API_KEY="local-dev-token"
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123" claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
``` ```
If Ollama is running without auth and your build accepts authless local OpenAI-compatible servers, `unset OPENAI_API_KEY` is also acceptable. Use a placeholder token rather than a real cloud API key for local testing. `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 ## llama.cpp server

1
rust/Cargo.lock generated
View File

@@ -2244,7 +2244,6 @@ version = "0.1.3"
dependencies = [ dependencies = [
"api", "api",
"commands", "commands",
"compat-harness",
"crossterm", "crossterm",
"log", "log",
"mock-anthropic-service", "mock-anthropic-service",

View File

@@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
cargo build --workspace cargo build --workspace
# Run the interactive REPL # Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-6 cargo run -p rusty-claude-cli -- --model claude-opus-4-7
# One-shot prompt # One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase" cargo run -p rusty-claude-cli -- prompt "explain this codebase"
@@ -87,7 +87,7 @@ Primary artifacts:
| Sub-agent / agent surfaces | ✅ | | Sub-agent / agent surfaces | ✅ |
| Todo tracking | ✅ | | Todo tracking | ✅ |
| Notebook editing | ✅ | | Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ | | CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ | | Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Permission system | ✅ | | Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ | | MCP server lifecycle + inspection | ✅ |
@@ -100,7 +100,7 @@ Primary artifacts:
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ | | Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ | | Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
| Plugin management surfaces | ✅ | | Plugin management surfaces | ✅ |
| Skills inventory / install surfaces | ✅ | | Skills inventory / install / uninstall surfaces | ✅ |
| Machine-readable JSON output across core CLI surfaces | ✅ | | Machine-readable JSON output across core CLI surfaces | ✅ |
## Model Aliases ## Model Aliases
@@ -109,7 +109,7 @@ Short names resolve to the latest model versions:
| Alias | Resolves To | | Alias | Resolves To |
|-------|------------| |-------|------------|
| `opus` | `claude-opus-4-6` | | `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` | | `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` | | `haiku` | `claude-haiku-4-5-20251213` |
@@ -122,10 +122,11 @@ claw [OPTIONS] [COMMAND]
Flags: Flags:
--model MODEL --model MODEL
--output-format text|json --output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
--permission-mode MODE --permission-mode MODE
--dangerously-skip-permissions --cwd PATH, -C PATH, --directory PATH
--allowedTools TOOLS --dangerously-skip-permissions, --skip-permissions
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
--resume [SESSION.jsonl|session-id|latest] --resume [SESSION.jsonl|session-id|latest]
--version, -V --version, -V
@@ -146,6 +147,13 @@ 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`. `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.
The command surface is moving quickly. For the canonical live help text, run: The command surface is moving quickly. For the canonical live help text, run:
@@ -166,8 +174,8 @@ The REPL now exposes a much broader surface than the original minimal shell:
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`) - plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
Notable claw-first surfaces now available directly in slash form: Notable claw-first surfaces now available directly in slash form:
- `/skills [list|install <path>|help]` - `/skills [list|show <name>|install <path>|uninstall <name>|help]`
- `/agents [list|help]` - `/agents [list|show <name>|create <name>|help]`
- `/mcp [list|show <server>|help]` - `/mcp [list|show <server>|help]`
- `/doctor` - `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]` - `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
@@ -184,7 +192,7 @@ rust/
└── crates/ └── crates/
├── api/ # Provider clients + streaming + request preflight ├── api/ # Provider clients + streaming + request preflight
├── commands/ # Shared slash-command registry + help rendering ├── commands/ # Shared slash-command registry + help rendering
├── compat-harness/ # TS manifest extraction harness ├── compat-harness/ # Compatibility/parity harness utilities
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock ├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces ├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop ├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
@@ -197,7 +205,7 @@ rust/
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight - **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering - **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
- **compat-harness** — extracts tool/prompt manifests from upstream TS source - **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs - **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces - **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
- **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking - **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
@@ -210,8 +218,8 @@ rust/
- **~20K lines** of Rust - **~20K lines** of Rust
- **9 crates** in workspace - **9 crates** in workspace
- **Binary name:** `claw` - **Binary name:** `claw`
- **Default model:** `claude-opus-4-6` - **Default model:** `claude-opus-4-7`
- **Default permissions:** `danger-full-access` - **Default permissions:** `workspace-write`
## License ## License

View File

@@ -32,6 +32,14 @@ impl ProviderClient {
OpenAiCompatConfig::xai(), OpenAiCompatConfig::xai(),
)?)), )?)),
ProviderKind::OpenAi => { ProviderKind::OpenAi => {
// 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 // DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which // speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com. // reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
@@ -45,6 +53,7 @@ impl ProviderClient {
} }
} }
} }
}
#[must_use] #[must_use]
pub const fn provider_kind(&self) -> ProviderKind { pub const fn provider_kind(&self) -> ProviderKind {
@@ -161,7 +170,7 @@ mod tests {
#[test] #[test]
fn resolves_existing_and_grok_aliases() { fn resolves_existing_and_grok_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
assert_eq!(resolve_model_alias("grok"), "grok-3"); assert_eq!(resolve_model_alias("grok"), "grok-3");
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini"); assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
} }
@@ -235,4 +244,22 @@ mod tests {
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"), other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
} }
} }
#[test]
fn local_openai_base_url_routes_authless_ollama_models() {
let _lock = env_lock();
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", None);
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model("qwen2.5-coder:7b")
.expect("local model should route to OpenAI-compatible client without auth");
match client {
ProviderClient::OpenAi(openai_client) => {
assert_eq!(openai_client.base_url(), "http://127.0.0.1:11434/v1")
}
other => panic!("Expected ProviderClient::OpenAi for local model, got: {other:?}"),
}
}
} }

View File

@@ -20,6 +20,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"completion tokens", "completion tokens",
"prompt tokens", "prompt tokens",
"request is too large", "request is too large",
"no parseable body",
]; ];
#[derive(Debug)] #[derive(Debug)]
@@ -60,6 +61,9 @@ pub enum ApiError {
retryable: bool, retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413) /// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>, 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 { RetriesExhausted {
attempts: u32, attempts: u32,
@@ -128,6 +132,17 @@ impl ApiError {
} }
#[must_use] #[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 { pub fn is_retryable(&self) -> bool {
match self { match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(), Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
@@ -311,6 +326,36 @@ impl Display for ApiError {
f, f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}" "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 { Self::Api {
status, status,
error_type, error_type,
@@ -499,6 +544,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_generic_fatal_wrapper()); assert!(error.is_generic_fatal_wrapper());
@@ -522,6 +568,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}), }),
}; };
@@ -543,6 +590,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_context_window_failure()); assert!(error.is_context_window_failure());
@@ -563,6 +611,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_context_window_failure()); assert!(error.is_context_window_failure());

View File

@@ -1,9 +1,69 @@
use std::time::Duration;
use crate::error::ApiError; use crate::error::ApiError;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"]; const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"]; const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_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 /// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and /// outbound HTTP client. Captured up front so callers can inspect, log, and
/// test the resolved configuration without re-reading the process environment. /// 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 /// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`. /// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> { 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 /// Infallible counterpart to [`build_http_client`] for constructors that
@@ -71,7 +131,8 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
/// first outbound request instead of at construction time. /// first outbound request instead of at construction time.
#[must_use] #[must_use]
pub fn build_http_client_or_default() -> reqwest::Client { pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client().unwrap_or_else(|_| { build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
.unwrap_or_else(|_| {
reqwest::Client::builder() reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1") .user_agent("clawd-rust-tools/0.1")
.build() .build()
@@ -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 /// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request. /// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> { 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() let mut builder = reqwest::Client::builder()
.no_proxy() .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 let no_proxy = config
.no_proxy .no_proxy
@@ -131,7 +203,7 @@ where
mod tests { mod tests {
use std::collections::HashMap; 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 { fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs let map: HashMap<String, String> = pairs
@@ -143,30 +215,19 @@ mod tests {
#[test] #[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() { fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]); let config = config_from_map(&[]);
assert!(config.is_empty());
// when
let empty = config.is_empty();
// then
assert!(empty);
assert_eq!(config, ProxyConfig::default()); assert_eq!(config, ProxyConfig::default());
} }
#[test] #[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() { fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [ let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"), ("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"), ("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"), ("NO_PROXY", "localhost,127.0.0.1,.corp"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://proxy.internal:3128") Some("http://proxy.internal:3128")
@@ -184,17 +245,12 @@ mod tests {
#[test] #[test]
fn proxy_config_falls_back_to_lowercase_keys() { fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [ let pairs = [
("http_proxy", "http://lower.internal:3128"), ("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"), ("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"), ("no_proxy", ".lower"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://lower.internal:3128") Some("http://lower.internal:3128")
@@ -208,16 +264,11 @@ mod tests {
#[test] #[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() { fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [ let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"), ("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"), ("http_proxy", "http://lower.internal:3128"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://upper.internal:3128") Some("http://upper.internal:3128")
@@ -226,59 +277,39 @@ mod tests {
#[test] #[test]
fn proxy_config_treats_empty_strings_as_unset() { fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")]; let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none()); assert!(config.http_proxy.is_none());
} }
#[test] #[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() { fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default(); let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() { fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()), http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()), https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()), no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None, proxy_url: None,
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() { fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: None, http_proxy: None,
https_proxy: Some("not a url".to_string()), https_proxy: Some("not a url".to_string()),
no_proxy: None, no_proxy: None,
proxy_url: None, proxy_url: None,
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure"); let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!( assert!(
matches!(error, crate::error::ApiError::Http(_)), matches!(error, crate::error::ApiError::Http(_)),
@@ -288,10 +319,7 @@ mod tests {
#[test] #[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() { fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128"); let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!( assert_eq!(
config.proxy_url.as_deref(), config.proxy_url.as_deref(),
Some("http://unified.internal:3128") Some("http://unified.internal:3128")
@@ -303,49 +331,56 @@ mod tests {
#[test] #[test]
fn build_http_client_succeeds_with_unified_proxy_url() { fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig { let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()), proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()), no_proxy: Some("localhost".to_string()),
..ProxyConfig::default() ..ProxyConfig::default()
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() { fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()), http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()), https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None, no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()), proxy_url: Some("http://unified.internal:3128".to_string()),
}; };
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() { fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url"); let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!( assert!(
matches!(result, Err(crate::error::ApiError::Http(_))), matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}" "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 error::ApiError;
pub use http_client::{ 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::{ pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord, CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,

View File

@@ -211,6 +211,19 @@ impl AnthropicClient {
self 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] #[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self { pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer); self.session_tracer = Some(session_tracer);
@@ -454,7 +467,13 @@ impl AnthropicClient {
break; 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 { Err(ApiError::RetriesExhausted {
@@ -468,8 +487,7 @@ impl AnthropicClient {
request: &MessageRequest, request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> { ) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut request_body = self.request_profile.render_json_body(request)?; let request_body = render_standard_messages_body(&self.request_profile, request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_builder = self.build_request(&request_url).json(&request_body); let request_builder = self.build_request(&request_url).json(&request_body);
request_builder.send().await.map_err(ApiError::from) request_builder.send().await.map_err(ApiError::from)
} }
@@ -529,8 +547,7 @@ impl AnthropicClient {
"{}/v1/messages/count_tokens", "{}/v1/messages/count_tokens",
self.base_url.trim_end_matches('/') self.base_url.trim_end_matches('/')
); );
let mut request_body = self.request_profile.render_json_body(request)?; let request_body = render_standard_messages_body(&self.request_profile, request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let response = self let response = self
.build_request(&request_url) .build_request(&request_url)
.json(&request_body) .json(&request_body)
@@ -868,10 +885,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(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 body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status); let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
Err(ApiError::Api { Err(ApiError::Api {
status, status,
@@ -885,13 +904,44 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body, body,
retryable, retryable,
suggested_action: None, 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 { const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) 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 /// 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 /// 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 /// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
@@ -910,6 +960,8 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
..
} = error } = error
else { else {
return error; return error;
@@ -923,6 +975,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
} }
let Some(bearer_token) = auth.bearer_token() else { let Some(bearer_token) = auth.bearer_token() else {
@@ -934,6 +987,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
}; };
if !bearer_token.starts_with("sk-ant-") { if !bearer_token.starts_with("sk-ant-") {
@@ -945,6 +999,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
} }
// Only append the hint when the AuthSource is pure BearerToken. If both // Only append the hint when the AuthSource is pure BearerToken. If both
@@ -960,6 +1015,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
} }
let enriched_message = match message { let enriched_message = match message {
@@ -974,9 +1030,25 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
} }
} }
fn anthropic_wire_model(model: &str) -> &str {
model.strip_prefix("anthropic/").unwrap_or(model)
}
fn render_standard_messages_body(
request_profile: &AnthropicRequestProfile,
request: &MessageRequest,
) -> Result<Value, serde_json::Error> {
let mut wire_request = request.clone();
wire_request.model = anthropic_wire_model(&request.model).to_string();
let mut body = request_profile.render_json_body(&wire_request)?;
strip_unsupported_beta_body_fields(&mut body);
Ok(body)
}
/// Remove beta-only body fields that the standard `/v1/messages` and /// Remove beta-only body fields that the standard `/v1/messages` and
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not /// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta` /// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
@@ -1550,6 +1622,27 @@ mod tests {
); );
} }
#[test]
fn standard_messages_body_strips_anthropic_routing_prefix() {
let client = AnthropicClient::new("test-key");
let request = MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
tools: None,
tool_choice: None,
stream: false,
..Default::default()
};
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
.expect("body should render");
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
assert!(rendered.get("betas").is_none());
}
#[test] #[test]
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() { fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
// given // given
@@ -1562,6 +1655,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1603,6 +1697,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1632,6 +1727,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1660,6 +1756,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1685,6 +1782,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when

View File

@@ -211,7 +211,7 @@ pub fn resolve_model_alias(model: &str) -> String {
.find_map(|(alias, metadata)| { .find_map(|(alias, metadata)| {
(*alias == lower).then_some(match metadata.provider { (*alias == lower).then_some(match metadata.provider {
ProviderKind::Anthropic => match *alias { ProviderKind::Anthropic => match *alias {
"opus" => "claude-opus-4-6", "opus" => "claude-opus-4-7",
"sonnet" => "claude-sonnet-4-6", "sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213", "haiku" => "claude-haiku-4-5-20251213",
_ => trimmed, _ => trimmed,
@@ -262,6 +262,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL, default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
}); });
} }
if canonical.starts_with("local/") {
return Some(ProviderMetadata {
provider: ProviderKind::OpenAi,
auth_env: "OPENAI_API_KEY",
base_url_env: "OPENAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
});
}
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare // Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.) // qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1. // to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
@@ -337,17 +345,26 @@ pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
} }
} }
fn looks_like_local_openai_model(model: &str) -> bool {
model.contains(':') || model.contains('.')
}
#[must_use] #[must_use]
pub fn detect_provider_kind(model: &str) -> ProviderKind { pub fn detect_provider_kind(model: &str) -> ProviderKind {
if let Some(metadata) = metadata_for_model(model) { // 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; return metadata.provider;
} }
// When OPENAI_BASE_URL is set, the user explicitly configured an // When OPENAI_BASE_URL is set and the unknown model name looks like a
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback // local server tag (for example `llama3.2` or `qwen2.5-coder:7b`), prefer
// even when the model name has no recognized prefix — this is the // the OpenAI-compatible endpoint over ambient Anthropic credentials.
// common case for local providers (Ollama, LM Studio, vLLM, etc.) if std::env::var_os("OPENAI_BASE_URL").is_some()
// where model names like "qwen2.5-coder:7b" don't match any prefix. && looks_like_local_openai_model(&resolved_model)
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
{ {
return ProviderKind::OpenAi; return ProviderKind::OpenAi;
} }
@@ -608,7 +625,7 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model); let canonical = resolve_model_alias(model);
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str()); let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
match base_model { match base_model {
"claude-opus-4-6" => Some(ModelTokenLimit { "claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000, max_output_tokens: 32_000,
context_window_tokens: 200_000, context_window_tokens: 200_000,
}), }),
@@ -1042,6 +1059,18 @@ mod tests {
assert_eq!(kind2, ProviderKind::OpenAi); assert_eq!(kind2, ProviderKind::OpenAi);
} }
#[test]
fn local_prefix_routes_to_openai_not_anthropic() {
let meta = super::metadata_for_model("local/Qwen/Qwen3.6-27B-FP8")
.expect("local/ prefix must resolve to OpenAI-compatible metadata");
assert_eq!(meta.provider, ProviderKind::OpenAi);
assert_eq!(meta.auth_env, "OPENAI_API_KEY");
assert_eq!(meta.base_url_env, "OPENAI_BASE_URL");
let kind = detect_provider_kind("local/Qwen/Qwen3.6-27B-FP8");
assert_eq!(kind, ProviderKind::OpenAi);
}
#[test] #[test]
fn qwen_prefix_routes_to_dashscope_not_anthropic() { fn qwen_prefix_routes_to_dashscope_not_anthropic() {
// User request from Discord #clawcode-get-help: web3g wants to use // User request from Discord #clawcode-get-help: web3g wants to use

View File

@@ -1,5 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::{BTreeMap, VecDeque}; use std::collections::{BTreeMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -48,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 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) 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 { impl OpenAiCompatConfig {
#[must_use] #[must_use]
pub const fn xai() -> Self { pub const fn xai() -> Self {
@@ -131,13 +140,38 @@ impl OpenAiCompatClient {
} }
pub fn from_env(config: OpenAiCompatConfig) -> Result<Self, ApiError> { pub fn from_env(config: OpenAiCompatConfig) -> Result<Self, ApiError> {
let Some(api_key) = read_env_non_empty(config.api_key_env)? else { let base_url = read_base_url(config);
let api_key = match read_env_non_empty(config.api_key_env)? {
Some(api_key) => api_key,
None if config.provider_name == "OpenAI"
&& is_local_openai_compatible_base_url(&base_url) =>
{
"local-dev-token".to_string()
}
None => {
return Err(ApiError::missing_credentials( return Err(ApiError::missing_credentials(
config.provider_name, config.provider_name,
config.credential_env_vars(), config.credential_env_vars(),
)); ));
}
}; };
Ok(Self::new(api_key, config)) 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] #[must_use]
@@ -165,6 +199,18 @@ impl OpenAiCompatClient {
self 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( pub async fn send_message(
&self, &self,
request: &MessageRequest, request: &MessageRequest,
@@ -207,6 +253,7 @@ impl OpenAiCompatClient {
reqwest::StatusCode::from_u16(code.unwrap_or(400)) reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST), .unwrap_or(reqwest::StatusCode::BAD_REQUEST),
), ),
retry_after: None,
}); });
} }
} }
@@ -260,7 +307,12 @@ impl OpenAiCompatClient {
break retryable_error; 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 { Err(ApiError::RetriesExhausted {
@@ -915,14 +967,18 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire. /// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
/// The prefix is used only to select transport; the backend expects the /// The prefix is used only to select transport; the backend expects the
/// bare model id. /// bare model id. Use `local/` to force OpenAI-compatible routing while
/// preserving any slashes that follow the prefix.
#[allow(dead_code)] #[allow(dead_code)]
fn strip_routing_prefix(model: &str) -> &str { fn strip_routing_prefix(model: &str) -> &str {
if let Some(pos) = model.find('/') { if let Some(pos) = model.find('/') {
let prefix = &model[..pos]; let prefix = &model[..pos];
// Only strip if the prefix before "/" is a known routing prefix, // Only strip if the prefix before "/" is a known routing prefix,
// not if "/" appears in the middle of the model name for other reasons. // not if "/" appears in the middle of the model name for other reasons.
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi") { if matches!(
prefix,
"openai" | "xai" | "grok" | "qwen" | "kimi" | "local"
) {
&model[pos + 1..] &model[pos + 1..]
} else { } else {
model model
@@ -932,6 +988,44 @@ fn strip_routing_prefix(model: &str) -> &str {
} }
} }
fn normalize_base_url_for_model_routing(url: &str) -> &str {
let trimmed = url.trim_end_matches('/');
trimmed
.strip_suffix("/chat/completions")
.map(|value| value.trim_end_matches('/'))
.unwrap_or(trimmed)
}
fn url_host(url: &str) -> &str {
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host_port)| host_port);
if host_port.starts_with('[') {
return host_port
.split(']')
.next()
.unwrap_or("")
.trim_start_matches('[');
}
host_port.split(':').next().unwrap_or("")
}
fn is_local_openai_compatible_base_url(url: &str) -> bool {
let host = url_host(url.trim());
if host.eq_ignore_ascii_case("localhost") || host == "::1" {
return true;
}
let Ok(address) = host.parse::<Ipv4Addr>() else {
return false;
};
let [first, second, ..] = address.octets();
matches!(first, 10 | 127)
|| first == 192 && second == 168
|| first == 172 && (16..=31).contains(&second)
}
fn wire_model_for_base_url<'a>( fn wire_model_for_base_url<'a>(
model: &'a str, model: &'a str,
config: OpenAiCompatConfig, config: OpenAiCompatConfig,
@@ -944,26 +1038,22 @@ fn wire_model_for_base_url<'a>(
let lowered_prefix = prefix.to_ascii_lowercase(); let lowered_prefix = prefix.to_ascii_lowercase();
if lowered_prefix == "openai" { if lowered_prefix == "openai" {
let trimmed_base_url = base_url.trim_end_matches('/'); let normalized_base_url = normalize_base_url_for_model_routing(base_url);
let default_openai = DEFAULT_OPENAI_BASE_URL.trim_end_matches('/'); let default_base_url = normalize_base_url_for_model_routing(config.default_base_url);
if matches!( if normalized_base_url.eq_ignore_ascii_case(default_base_url)
lowered_prefix.as_str(), || is_local_openai_compatible_base_url(base_url)
"xai" | "grok" | "kimi" | "gemini" | "gemma" {
) {
return Cow::Borrowed(&model[pos + 1..]); return Cow::Borrowed(&model[pos + 1..]);
} }
if config.provider_name == "OpenAI" && trimmed_base_url != default_openai {
// Only preserve the full slug if it's NOT a model we want to strip
if !model.contains("gemini") && !model.contains("gemma") {
return Cow::Borrowed(model); return Cow::Borrowed(model);
} }
}
return Cow::Borrowed(&model[pos + 1..]);
}
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") { if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
return Cow::Borrowed(&model[pos + 1..]); return Cow::Borrowed(&model[pos + 1..]);
} }
if lowered_prefix == "local" {
return Cow::Borrowed(&model[pos + 1..]);
}
Cow::Borrowed(model) Cow::Borrowed(model)
} }
@@ -1115,6 +1205,13 @@ fn build_chat_completion_request_for_base_url(
payload[key] = value.clone(); payload[key] = value.clone();
} }
// DeepSeek V4 Pro/Flash thinking mode requires this provider-specific opt-in
// and also requires assistant reasoning history to be echoed as `reasoning_content`.
// Apply it after extra_body so callers cannot accidentally override the required shape.
if model_requires_reasoning_content_in_history(wire_model) {
payload["thinking"] = json!({"type": "enabled"});
}
payload payload
} }
@@ -1172,16 +1269,19 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
InputContentBlock::ToolResult { .. } => {} InputContentBlock::ToolResult { .. } => {}
} }
} }
let include_reasoning = let needs_reasoning = model_requires_reasoning_content_in_history(model);
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty(); if text.is_empty() && tool_calls.is_empty() && reasoning.is_empty() {
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
Vec::new() Vec::new()
} else { } else {
let mut msg = serde_json::json!({ let mut msg = serde_json::json!({
"role": "assistant", "role": "assistant",
"content": (!text.is_empty()).then_some(text),
}); });
if include_reasoning { if !text.is_empty() {
msg["content"] = json!(text);
} else if !needs_reasoning {
msg["content"] = Value::Null;
}
if needs_reasoning {
msg["reasoning_content"] = json!(reasoning); msg["reasoning_content"] = json!(reasoning);
} }
// Only include tool_calls when non-empty: some providers reject // Only include tool_calls when non-empty: some providers reject
@@ -1503,6 +1603,7 @@ fn parse_sse_frame(
body: trimmed.chars().take(500).collect(), body: trimmed.chars().take(500).collect(),
retryable: false, retryable: false,
suggested_action: suggested_action_for_status(status), suggested_action: suggested_action_for_status(status),
retry_after: None,
}); });
} }
} }
@@ -1518,6 +1619,7 @@ fn parse_sse_frame(
body: trimmed.chars().take(200).collect(), body: trimmed.chars().take(200).collect(),
retryable: false, retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()), suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
}); });
} }
return Ok(None); return Ok(None);
@@ -1553,6 +1655,7 @@ fn parse_sse_frame(
body: payload.clone(), body: payload.clone(),
retryable: false, retryable: false,
suggested_action: suggested_action_for_status(status), suggested_action: suggested_action_for_status(status),
retry_after: None,
}); });
} }
} }
@@ -1569,6 +1672,7 @@ fn parse_sse_frame(
body: payload.chars().take(200).collect(), body: payload.chars().take(200).collect(),
retryable: false, retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()), suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
}); });
} }
serde_json::from_str::<ChatCompletionChunk>(&payload) serde_json::from_str::<ChatCompletionChunk>(&payload)
@@ -1620,10 +1724,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(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 body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status); let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
let suggested_action = suggested_action_for_status(status); let suggested_action = suggested_action_for_status(status);
@@ -1639,13 +1745,43 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body, body,
retryable, retryable,
suggested_action, 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 { const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) 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. /// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail. /// This provides actionable guidance when API requests fail.
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> { fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {
@@ -1698,6 +1834,7 @@ mod tests {
ToolChoice, ToolDefinition, ToolResultContentBlock, ToolChoice, ToolDefinition, ToolResultContentBlock,
}; };
use serde_json::json; use serde_json::json;
use std::borrow::Cow;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
@@ -1796,6 +1933,31 @@ mod tests {
assert_eq!(assistant["content"], json!("answer")); assert_eq!(assistant["content"], json!("answer"));
} }
#[test]
fn deepseek_v4_assistant_with_only_tool_calls_omits_content_and_includes_reasoning() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "get_weather".to_string(),
input: json!({"city": "Paris"}),
}],
}],
stream: false,
..Default::default()
};
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
let assistant = &payload["messages"][0];
assert!(assistant.get("content").is_none());
assert_eq!(assistant["reasoning_content"], json!(""));
assert_eq!(assistant["tool_calls"].as_array().map(Vec::len), Some(1));
}
#[test] #[test]
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() { fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
// Given an assistant history turn containing thinking. // Given an assistant history turn containing thinking.
@@ -1982,6 +2144,49 @@ mod tests {
assert_eq!(payload["reasoning_effort"], json!("high")); assert_eq!(payload["reasoning_effort"], json!("high"));
} }
#[test]
fn deepseek_v4_request_includes_thinking_parameter() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "deepseek-v4-pro".to_string(),
max_tokens: 1024,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert_eq!(payload["thinking"], json!({"type": "enabled"}));
assert_eq!(payload["model"], json!("deepseek-v4-pro"));
let mut extra_body = BTreeMap::new();
extra_body.insert("thinking".to_string(), json!({"type": "disabled"}));
let payload_with_override = build_chat_completion_request(
&MessageRequest {
model: "openai/deepseek-v4-flash".to_string(),
max_tokens: 1024,
messages: vec![InputMessage::user_text("hello")],
extra_body,
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert_eq!(
payload_with_override["thinking"],
json!({"type": "enabled"})
);
let non_deepseek_payload = build_chat_completion_request(
&MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::openai(),
);
assert!(non_deepseek_payload.get("thinking").is_none());
}
#[test] #[test]
fn reasoning_effort_omitted_when_not_set() { fn reasoning_effort_omitted_when_not_set() {
let payload = build_chat_completion_request( let payload = build_chat_completion_request(
@@ -2069,6 +2274,28 @@ mod tests {
)); ));
} }
#[test]
fn local_openai_base_url_does_not_require_api_key() {
let _lock = env_lock();
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
let original_api_key = std::env::var_os("OPENAI_API_KEY");
std::env::set_var("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
std::env::remove_var("OPENAI_API_KEY");
let client = OpenAiCompatClient::from_env(OpenAiCompatConfig::openai())
.expect("local OpenAI-compatible endpoint should not require an API key");
assert_eq!(client.base_url(), "http://127.0.0.1:11434/v1");
match original_base_url {
Some(value) => std::env::set_var("OPENAI_BASE_URL", value),
None => std::env::remove_var("OPENAI_BASE_URL"),
}
match original_api_key {
Some(value) => std::env::set_var("OPENAI_API_KEY", value),
None => std::env::remove_var("OPENAI_API_KEY"),
}
}
#[test] #[test]
fn endpoint_builder_accepts_base_urls_and_full_endpoints() { fn endpoint_builder_accepts_base_urls_and_full_endpoints() {
assert_eq!( assert_eq!(
@@ -2684,6 +2911,66 @@ mod tests {
} }
} }
#[test]
fn wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways() {
assert_eq!(
super::wire_model_for_base_url(
"openai/gpt-4o",
OpenAiCompatConfig::openai(),
super::DEFAULT_OPENAI_BASE_URL,
),
Cow::Borrowed("gpt-4o")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/qwen2.5-coder:7b",
OpenAiCompatConfig::openai(),
"http://127.0.0.1:11434/v1",
),
Cow::Borrowed("qwen2.5-coder:7b")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/llama3.2",
OpenAiCompatConfig::openai(),
"http://localhost:11434/v1/chat/completions",
),
Cow::Borrowed("llama3.2")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/gpt-4.1-mini",
OpenAiCompatConfig::openai(),
"https://openrouter.ai/api/v1",
),
Cow::Borrowed("openai/gpt-4.1-mini")
);
assert_eq!(
super::wire_model_for_base_url(
"openai/gpt-4.1-mini",
OpenAiCompatConfig::openai(),
"https://not-localhost.example.com/v1",
),
Cow::Borrowed("openai/gpt-4.1-mini")
);
}
#[test]
fn local_routing_prefix_strips_only_escape_hatch() {
assert_eq!(
super::strip_routing_prefix("local/Qwen/Qwen3.6-27B-FP8"),
"Qwen/Qwen3.6-27B-FP8"
);
assert_eq!(
super::wire_model_for_base_url(
"local/Qwen/Qwen3.6-27B-FP8",
OpenAiCompatConfig::openai(),
"http://127.0.0.1:8000/v1",
),
Cow::Borrowed("Qwen/Qwen3.6-27B-FP8")
);
}
#[test] #[test]
fn check_request_body_size_allows_large_requests_for_openai() { fn check_request_body_size_allows_large_requests_for_openai() {
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit // Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit

View File

@@ -103,6 +103,58 @@ async fn send_message_posts_json_and_parses_response() {
); );
} }
#[tokio::test]
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
http_response(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_prefixed\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
"}"
),
),
],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client
.send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
let captured = state.lock().await;
assert_eq!(
captured.len(),
2,
"count_tokens and messages requests should be captured"
);
let count_tokens_body: serde_json::Value =
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
let messages_body: serde_json::Value =
serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
}
#[tokio::test] #[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() { async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -159,10 +159,15 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
}, },
] ]
); );
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["thinking"], json!({"type": "enabled"}));
} }
#[tokio::test] #[tokio::test]
async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params() { async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!( let body = concat!(
"{", "{",
@@ -206,7 +211,7 @@ async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params()
let captured = state.lock().await; let captured = state.lock().await;
let request = captured.first().expect("captured request"); let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body"); let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("openai/gpt-4.1-mini")); assert_eq!(body["model"], json!("gpt-4.1-mini"));
assert_eq!( assert_eq!(
body["web_search_options"], body["web_search_options"],
json!({"search_context_size": "low"}) json!({"search_context_size": "low"})

File diff suppressed because it is too large Load Diff

View File

@@ -330,20 +330,24 @@ fn prepare_tokio_command(
prepare_sandbox_dirs(cwd); prepare_sandbox_dirs(cwd);
} }
let mut prepared =
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) { if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut prepared = TokioCommand::new(launcher.program); let mut cmd = TokioCommand::new(launcher.program);
prepared.args(launcher.args); cmd.args(launcher.args);
prepared.current_dir(cwd); cmd.envs(launcher.env);
prepared.envs(launcher.env); cmd
return prepared; } else {
} let mut cmd = TokioCommand::new("sh");
cmd.arg("-lc").arg(command);
let mut prepared = TokioCommand::new("sh");
prepared.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active { if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home")); cmd.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp")); cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
} }
cmd
};
prepared.current_dir(cwd);
prepared.stdin(Stdio::null());
prepared prepared
} }
@@ -419,6 +423,27 @@ mod tests {
assert_eq!(structured[0]["event"], "test.hung"); assert_eq!(structured[0]["event"], "test.hung");
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout"); assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
} }
#[test]
fn prevents_stdin_hangs_by_redirecting_to_null() {
let output = execute_bash(BashCommandInput {
command: String::from("cat"),
timeout: Some(2_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(true),
namespace_restrictions: None,
isolate_network: None,
filesystem_mode: None,
allowed_mounts: None,
})
.expect("bash command should execute cleanly");
assert!(
!output.interrupted,
"Command hung and was cut off by the timeout!"
);
}
} }
/// Maximum output bytes before truncation (16 KiB, matching upstream). /// Maximum output bytes before truncation (16 KiB, matching upstream).

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,8 @@ enum FieldType {
Bool, Bool,
Object, Object,
StringArray, StringArray,
HookArray,
RulesImport,
Number, Number,
} }
@@ -102,6 +104,8 @@ impl FieldType {
Self::Bool => "a boolean", Self::Bool => "a boolean",
Self::Object => "an object", Self::Object => "an object",
Self::StringArray => "an array of strings", Self::StringArray => "an array of strings",
Self::RulesImport => "a string or an array of strings",
Self::HookArray => "an array of strings or hook objects",
Self::Number => "a number", Self::Number => "a number",
} }
} }
@@ -114,6 +118,13 @@ impl FieldType {
Self::StringArray => value Self::StringArray => value
.as_array() .as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => true,
Self::RulesImport => {
value.as_str().is_some()
|| value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
}
Self::Number => value.as_i64().is_some(), Self::Number => value.as_i64().is_some(),
} }
} }
@@ -201,20 +212,24 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "provider", name: "provider",
expected: FieldType::Object, expected: FieldType::Object,
}, },
FieldSpec {
name: "rulesImport",
expected: FieldType::RulesImport,
},
]; ];
const HOOKS_FIELDS: &[FieldSpec] = &[ const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec { FieldSpec {
name: "PreToolUse", name: "PreToolUse",
expected: FieldType::StringArray, expected: FieldType::HookArray,
}, },
FieldSpec { FieldSpec {
name: "PostToolUse", name: "PostToolUse",
expected: FieldType::StringArray, expected: FieldType::HookArray,
}, },
FieldSpec { FieldSpec {
name: "PostToolUseFailure", name: "PostToolUseFailure",
expected: FieldType::StringArray, expected: FieldType::HookArray,
}, },
]; ];
@@ -406,9 +421,10 @@ fn validate_object_keys(
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) { } else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error. // Deprecated key — handled separately, not an unknown-key error.
} else { } else {
// Unknown key. // Unknown key — preserve compatibility by surfacing it as a warning
// instead of blocking otherwise valid config files.
let suggestion = suggest_field(key, &known_names); let suggestion = suggest_field(key, &known_names);
result.errors.push(ConfigDiagnostic { result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(), path: path_display.to_string(),
field: field_path, field: field_path,
line: find_key_line(source, key), line: find_key_line(source, key),
@@ -420,8 +436,56 @@ fn validate_object_keys(
result 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> { fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase(); 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 candidates
.iter() .iter()
.filter_map(|candidate| { .filter_map(|candidate| {
@@ -491,6 +555,7 @@ pub fn validate_config_file(
source, source,
&path_display, &path_display,
)); ));
result.merge(validate_hook_entry_format(hooks, source, &path_display));
} }
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) { if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys( result.merge(validate_object_keys(
@@ -587,10 +652,11 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "unknownField"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "unknownField");
assert!(matches!( assert!(matches!(
result.errors[0].kind, result.warnings[0].kind,
DiagnosticKind::UnknownKey { .. } DiagnosticKind::UnknownKey { .. }
)); ));
} }
@@ -670,9 +736,10 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].line, Some(3)); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.errors[0].field, "badKey"); assert_eq!(result.warnings[0].line, Some(3));
assert_eq!(result.warnings[0].field, "badKey");
} }
#[test] #[test]
@@ -693,7 +760,7 @@ mod tests {
#[test] #[test]
fn validates_nested_hooks_keys() { fn validates_nested_hooks_keys() {
// given // 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 parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object"); let object = parsed.as_object().expect("object");
@@ -701,8 +768,64 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert!(result.errors.is_empty());
assert_eq!(
result.warnings.len(),
1,
"expected only the unknown key warning, got {:?}",
result.warnings
);
assert_eq!(result.warnings[0].field, "hooks.BadHook");
}
#[test]
fn validates_object_style_hook_entries() {
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
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!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
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!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn validates_rules_import_string_and_array_forms() {
for source in [
r#"{"rulesImport":"auto"}"#,
r#"{"rulesImport":"none"}"#,
r#"{"rulesImport":["cursor","copilot"]}"#,
] {
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!(result.errors.is_empty(), "{source}: {:?}", result.errors);
}
}
#[test]
fn rejects_rules_import_wrong_type() {
let source = r#"{"rulesImport":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.len(), 1);
assert_eq!(result.errors[0].field, "hooks.BadHook"); assert_eq!(result.errors[0].field, "rulesImport");
} }
#[test] #[test]
@@ -716,8 +839,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "permissions.denyAll"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "permissions.denyAll");
} }
#[test] #[test]
@@ -731,8 +855,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "sandbox.containerMode"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
} }
#[test] #[test]
@@ -746,8 +871,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "plugins.autoUpdate"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
} }
#[test] #[test]
@@ -761,8 +887,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "oauth.secret"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "oauth.secret");
} }
#[test] #[test]
@@ -770,7 +897,7 @@ mod tests {
// given // given
let source = r#"{ let source = r#"{
"model": "opus", "model": "opus",
"hooks": {"PreToolUse": ["guard"]}, "hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]}, "permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {}, "mcpServers": {},
"sandbox": {"enabled": false} "sandbox": {"enabled": false}
@@ -797,8 +924,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
match &result.errors[0].kind { assert_eq!(result.warnings.len(), 1);
match &result.warnings[0].kind {
DiagnosticKind::UnknownKey { DiagnosticKind::UnknownKey {
suggestion: Some(s), suggestion: Some(s),
} => assert_eq!(s, "model"), } => assert_eq!(s, "model"),
@@ -809,7 +937,7 @@ mod tests {
#[test] #[test]
fn format_diagnostics_includes_all_entries() { fn format_diagnostics_includes_all_entries() {
// given // given
let source = r#"{"permissionMode": "plan", "badKey": 1}"#; let source = r#"{"model": 42, "badKey": 1}"#;
let parsed = JsonValue::parse(source).expect("valid json"); let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object"); let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
@@ -821,7 +949,7 @@ mod tests {
assert!(output.contains("warning:")); assert!(output.contains("warning:"));
assert!(output.contains("error:")); assert!(output.contains("error:"));
assert!(output.contains("badKey")); assert!(output.contains("badKey"));
assert!(output.contains("permissionMode")); assert!(output.contains("model"));
} }
#[test] #[test]

View File

@@ -204,6 +204,13 @@ where
self 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] #[must_use]
pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self { pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self {
self.hook_abort_signal = hook_abort_signal; self.hook_abort_signal = hook_abort_signal;

View File

@@ -11,7 +11,7 @@ use std::time::Duration;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
use crate::permissions::PermissionOverride; use crate::permissions::PermissionOverride;
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160; const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
@@ -182,7 +182,7 @@ impl HookRunner {
) -> HookRunResult { ) -> HookRunResult {
Self::run_commands( Self::run_commands(
HookEvent::PreToolUse, HookEvent::PreToolUse,
self.config.pre_tool_use(), self.config.pre_tool_use_entries(),
tool_name, tool_name,
tool_input, tool_input,
None, None,
@@ -232,7 +232,7 @@ impl HookRunner {
) -> HookRunResult { ) -> HookRunResult {
Self::run_commands( Self::run_commands(
HookEvent::PostToolUse, HookEvent::PostToolUse,
self.config.post_tool_use(), self.config.post_tool_use_entries(),
tool_name, tool_name,
tool_input, tool_input,
Some(tool_output), Some(tool_output),
@@ -282,7 +282,7 @@ impl HookRunner {
) -> HookRunResult { ) -> HookRunResult {
Self::run_commands( Self::run_commands(
HookEvent::PostToolUseFailure, HookEvent::PostToolUseFailure,
self.config.post_tool_use_failure(), self.config.post_tool_use_failure_entries(),
tool_name, tool_name,
tool_input, tool_input,
Some(tool_error), Some(tool_error),
@@ -312,7 +312,7 @@ impl HookRunner {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn run_commands( fn run_commands(
event: HookEvent, event: HookEvent,
commands: &[String], commands: &[RuntimeHookCommand],
tool_name: &str, tool_name: &str,
tool_input: &str, tool_input: &str,
tool_output: Option<&str>, tool_output: Option<&str>,
@@ -342,17 +342,21 @@ impl HookRunner {
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string(); let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut result = HookRunResult::allow(Vec::new()); let mut result = HookRunResult::allow(Vec::new());
for command in commands { for command in commands
.iter()
.filter(|command| command.matches_tool(tool_name))
{
let command_text = command.command();
if let Some(reporter) = reporter.as_deref_mut() { if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Started { reporter.on_event(&HookProgressEvent::Started {
event, event,
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
command: command.clone(), command: command_text.to_string(),
}); });
} }
match Self::run_command( match Self::run_command(
command, command_text,
event, event,
tool_name, tool_name,
tool_input, tool_input,
@@ -366,7 +370,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed { reporter.on_event(&HookProgressEvent::Completed {
event, event,
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
command: command.clone(), command: command_text.to_string(),
}); });
} }
merge_parsed_hook_output(&mut result, parsed); merge_parsed_hook_output(&mut result, parsed);
@@ -376,7 +380,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed { reporter.on_event(&HookProgressEvent::Completed {
event, event,
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
command: command.clone(), command: command_text.to_string(),
}); });
} }
merge_parsed_hook_output(&mut result, parsed); merge_parsed_hook_output(&mut result, parsed);
@@ -388,7 +392,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Completed { reporter.on_event(&HookProgressEvent::Completed {
event, event,
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
command: command.clone(), command: command_text.to_string(),
}); });
} }
merge_parsed_hook_output(&mut result, parsed); merge_parsed_hook_output(&mut result, parsed);
@@ -400,7 +404,7 @@ impl HookRunner {
reporter.on_event(&HookProgressEvent::Cancelled { reporter.on_event(&HookProgressEvent::Cancelled {
event, event,
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
command: command.clone(), command: command_text.to_string(),
}); });
} }
result.cancelled = true; result.cancelled = true;
@@ -737,7 +741,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
fn shell_command(command: &str) -> CommandWithStdin { fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)] #[cfg(windows)]
let mut command_builder = { let command_builder = {
let mut command_builder = Command::new("cmd"); let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command); command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder) CommandWithStdin::new(command_builder)
@@ -825,7 +829,7 @@ mod tests {
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
HookRunner, HookRunner,
}; };
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
use crate::permissions::PermissionOverride; use crate::permissions::PermissionOverride;
struct RecordingReporter { struct RecordingReporter {
@@ -851,6 +855,37 @@ mod tests {
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()])); assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
} }
#[test]
fn object_style_hook_matchers_filter_runtime_execution() {
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
vec![
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
RuntimeHookCommand::with_matcher(
shell_snippet("printf 'bash only'"),
Some("Bash".to_string()),
),
RuntimeHookCommand::with_matcher(
shell_snippet("printf 'read only'"),
Some("Read*".to_string()),
),
],
Vec::new(),
Vec::new(),
));
let read_result = runner.run_pre_tool_use("ReadFile", r#"{"path":"README.md"}"#);
let bash_result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert_eq!(
read_result,
HookRunResult::allow(vec!["legacy".to_string(), "read only".to_string()])
);
assert_eq!(
bash_result,
HookRunResult::allow(vec!["legacy".to_string(), "bash only".to_string()])
);
}
#[test] #[test]
fn denies_exit_code_two() { fn denies_exit_code_two() {
let runner = HookRunner::new(RuntimeHookConfig::new( let runner = HookRunner::new(RuntimeHookConfig::new(

View File

@@ -65,12 +65,14 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
}; };
pub use config::{ pub use config::{
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig, RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
}; };
pub use config_validate::{ pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -141,8 +143,9 @@ pub use policy_engine::{
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus, PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
}; };
pub use prompt::{ pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext, load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
}; };
pub use recovery_recipes::{ pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState, 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 { 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() path.to_owned()
} else { } else {
format!("{workspace_root}/{path}") format!("{workspace_root}/{path}")
}; };
let root = if workspace_root.ends_with('/') { let normalized = lexically_normalize(&combined);
workspace_root.to_owned() let root = lexically_normalize(workspace_root);
let root_with_slash = if root.ends_with('/') {
root.clone()
} else { } 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? /// 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 { fn is_read_only_command(command: &str) -> bool {
let first_token = command // Shell metacharacters that enable command chaining, substitution,
.split_whitespace() // piping, redirection, or subshells. Presence of any of these means we
.next() // cannot reason about the command from its leading token alone.
.unwrap_or("") const SHELL_METACHARS: &[char] = &[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
.rsplit('/') if command.contains(SHELL_METACHARS) {
.next() return false;
.unwrap_or(""); }
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!( matches!(
first_token, first_token,
@@ -237,8 +317,6 @@ fn is_read_only_command(command: &str) -> bool {
| "tr" | "tr"
| "cut" | "cut"
| "paste" | "paste"
| "tee"
| "xargs"
| "test" | "test"
| "true" | "true"
| "false" | "false"
@@ -257,18 +335,8 @@ fn is_read_only_command(command: &str) -> bool {
| "tree" | "tree"
| "jq" | "jq"
| "yq" | "yq"
| "python3"
| "python"
| "node"
| "ruby"
| "cargo"
| "rustc"
| "git"
| "gh"
) && !command.contains("-i ") ) && !command.contains("-i ")
&& !command.contains("--in-place") && !command.contains("--in-place")
&& !command.contains(" > ")
&& !command.contains(" >> ")
} }
#[cfg(test)] #[cfg(test)]
@@ -375,6 +443,91 @@ mod tests {
assert!(!is_read_only_command("sed -i 's/a/b/' file")); 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] #[test]
fn active_mode_returns_policy_mode() { fn active_mode_returns_policy_mode() {
// given // given

View File

@@ -149,7 +149,12 @@ impl PermissionPolicy {
.iter() .iter()
.map(|rule| PermissionRule::parse(rule)) .map(|rule| PermissionRule::parse(rule))
.collect(); .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 self
} }
@@ -375,7 +380,8 @@ impl PermissionRule {
let matcher = parse_rule_matcher(content); let matcher = parse_rule_matcher(content);
return Self { return Self {
raw: trimmed.to_string(), 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, matcher,
}; };
} }
@@ -384,7 +390,8 @@ impl PermissionRule {
Self { Self {
raw: trimmed.to_string(), 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, matcher: PermissionRuleMatcher::Any,
} }
} }

View File

@@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig}; use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
use crate::git_context::GitContext; use crate::git_context::GitContext;
/// Errors raised while assembling the final system prompt. /// Errors raised while assembling the final system prompt.
@@ -69,6 +69,18 @@ pub struct ContextFile {
pub content: String, 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. /// Project-local context injected into the rendered system prompt.
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectContext { pub struct ProjectContext {
@@ -86,7 +98,24 @@ impl ProjectContext {
current_date: impl Into<String>, current_date: impl Into<String>,
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let cwd = cwd.into(); let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd)?; let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
Ok(Self {
cwd,
current_date: current_date.into(),
git_status: None,
git_diff: None,
git_context: None,
instruction_files,
})
}
pub fn discover_with_rules_import(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
rules_import: &RulesImportConfig,
) -> std::io::Result<Self> {
let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
Ok(Self { Ok(Self {
cwd, cwd,
current_date: current_date.into(), current_date: current_date.into(),
@@ -109,6 +138,18 @@ impl ProjectContext {
} }
} }
fn discover_with_git_and_rules_import(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
rules_import: &RulesImportConfig,
) -> std::io::Result<ProjectContext> {
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
context.git_status = read_git_status(&context.cwd);
context.git_diff = read_git_diff(&context.cwd);
context.git_context = GitContext::detect(&context.cwd);
Ok(context)
}
/// Builder for the runtime system prompt and dynamic environment sections. /// Builder for the runtime system prompt and dynamic environment sections.
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemPromptBuilder { pub struct SystemPromptBuilder {
@@ -227,30 +268,81 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect() items.into_iter().map(|item| format!(" - {item}")).collect()
} }
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> { fn instruction_file_source(path: &Path) -> &'static str {
let mut directories = Vec::new(); let file_name = path.file_name().and_then(|name| name.to_str());
let mut cursor = Some(cwd); let parent_name = path
while let Some(dir) = cursor { .parent()
directories.push(dir.to_path_buf()); .and_then(|parent| parent.file_name())
cursor = dir.parent(); .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 = instruction_discovery_dirs(cwd);
directories.reverse(); directories.reverse();
let mut files = Vec::new(); let mut files = Vec::new();
for dir in directories { for dir in directories {
for candidate in [ for candidate in [
dir.join("CLAUDE.md"), dir.join("CLAUDE.md"),
dir.join("CLAW.md"),
dir.join("AGENTS.md"),
dir.join("CLAUDE.local.md"), dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"), dir.join(".claw").join("CLAUDE.md"),
dir.join(".claude").join("CLAUDE.md"),
dir.join(".claw").join("instructions.md"), dir.join(".claw").join("instructions.md"),
] { ] {
push_context_file(&mut files, candidate)?; push_context_file(&mut files, candidate)?;
} }
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
push_framework_imports(&mut files, &dir, rules_import)?
} }
Ok(dedupe_instruction_files(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<()> { fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
if path.is_dir() {
return Ok(());
}
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => { Ok(content) if !content.trim().is_empty() => {
files.push(ContextFile { path, content }); files.push(ContextFile { path, content });
@@ -262,6 +354,64 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
} }
} }
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
if dir.is_file() {
return Ok(());
}
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let mut paths = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file() && is_supported_rule_file(path))
.collect::<Vec<_>>();
paths.sort();
for path in paths {
push_context_file(files, path)?;
}
Ok(())
}
fn is_supported_rule_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"md" | "txt" | "mdc"
)
})
}
fn push_framework_imports(
files: &mut Vec<ContextFile>,
dir: &Path,
rules_import: &RulesImportConfig,
) -> std::io::Result<()> {
if rules_import.should_import("cursor") {
push_context_file(files, dir.join(".cursorrules"))?;
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
}
if rules_import.should_import("copilot") {
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
}
if rules_import.should_import("windsurf") {
push_context_file(files, dir.join(".windsurfrules"))?;
push_rules_dir(files, dir.join(".windsurfrules"))?;
}
if rules_import.should_import("plandex") {
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
}
if rules_import.should_import("crush") {
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
push_rules_dir(files, dir.join(".crush").join("rules"))?;
}
Ok(())
}
fn read_git_status(cwd: &Path) -> Option<String> { fn read_git_status(cwd: &Path) -> Option<String> {
let output = Command::new("git") let output = Command::new("git")
.args(["--no-optional-locks", "status", "--short", "--branch"]) .args(["--no-optional-locks", "status", "--short", "--branch"])
@@ -332,7 +482,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
]; ];
if !project_context.instruction_files.is_empty() { if !project_context.instruction_files.is_empty() {
bullets.push(format!( bullets.push(format!(
"Claude instruction files discovered: {}.", "Project instruction files discovered: {}.",
project_context.instruction_files.len() project_context.instruction_files.len()
)); ));
} }
@@ -367,7 +517,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
} }
fn render_instruction_files(files: &[ContextFile]) -> 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; let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files { for file in files {
if remaining_chars == 0 { if remaining_chars == 0 {
@@ -476,14 +626,30 @@ pub fn load_system_prompt(
model_family: ModelFamilyIdentity, model_family: ModelFamilyIdentity,
) -> Result<Vec<String>, PromptBuildError> { ) -> Result<Vec<String>, PromptBuildError> {
let cwd = cwd.into(); let cwd = cwd.into();
let project_context = ProjectContext::discover_with_git(&cwd, current_date.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 config = ConfigLoader::default_for(&cwd).load()?;
Ok(SystemPromptBuilder::new() let project_context =
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
let sections = SystemPromptBuilder::new()
.with_os(os_name, os_version) .with_os(os_name, os_version)
.with_model_family(model_family) .with_model_family(model_family)
.with_project_context(project_context) .with_project_context(project_context.clone())
.with_runtime_config(config) .with_runtime_config(config)
.build()) .build();
Ok((sections, project_context))
} }
fn render_config_section(config: &RuntimeConfig) -> String { fn render_config_section(config: &RuntimeConfig) -> String {
@@ -590,11 +756,84 @@ mod tests {
} }
} }
#[test]
fn discovers_claw_rules_files_in_sorted_order() {
let root = temp_dir();
let rules = root.join(".claw").join("rules");
let local_rules = root.join(".claw").join("rules.local");
fs::create_dir_all(&rules).expect("rules dir");
fs::create_dir_all(&local_rules).expect("local rules dir");
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let contents = context
.instruction_files
.iter()
.map(|file| file.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rules_import_none_suppresses_external_framework_rules() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
fs::write(
root.join(".claw").join("rules").join("project.md"),
"claw rule",
)
.expect("write claw rule");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
let context = ProjectContext::discover_with_rules_import(
&root,
"2026-03-31",
&crate::config::RulesImportConfig::None,
)
.expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("claw rule"));
assert!(!rendered.contains("cursor rule"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rules_import_list_loads_only_selected_framework_rules() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
fs::create_dir_all(root.join(".github")).expect("github dir");
fs::write(
root.join(".github").join("copilot-instructions.md"),
"copilot rule",
)
.expect("write copilot rule");
let context = ProjectContext::discover_with_rules_import(
&root,
"2026-03-31",
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
)
.expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
assert!(rendered.contains("copilot rule"));
assert!(!rendered.contains("cursor rule"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn discovers_instruction_files_from_ancestor_chain() { fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir(); let root = temp_dir();
let nested = root.join("apps").join("api"); let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir"); 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.md"), "root instructions").expect("write root instructions");
fs::write(root.join("CLAUDE.local.md"), "local instructions") fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions"); .expect("write local instructions");
@@ -636,11 +875,80 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
#[test]
fn discovers_agents_markdown_instruction_file() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
fs::write(root.join("AGENTS.md"), "agents-only instructions").expect("write AGENTS.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
assert!(context.instruction_files[0].path.ends_with("AGENTS.md"));
assert!(render_instruction_files(&context.instruction_files)
.contains("agents-only instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discovers_scoped_dot_claude_claude_markdown_instruction_file() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(
root.join(".claude").join("CLAUDE.md"),
"dot-claude-only instructions",
)
.expect("write .claude/CLAUDE.md");
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
assert!(context.instruction_files[0]
.path
.ends_with(".claude/CLAUDE.md"));
assert!(render_instruction_files(&context.instruction_files)
.contains("dot-claude-only instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
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"),
"dot claude instructions",
)
.expect("write .claude/CLAUDE.md");
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");
}
#[test] #[test]
fn dedupes_identical_instruction_content_across_scopes() { fn dedupes_identical_instruction_content_across_scopes() {
let root = temp_dir(); let root = temp_dir();
let nested = root.join("apps").join("api"); let nested = root.join("apps").join("api");
fs::create_dir_all(&nested).expect("nested dir"); 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(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested"); fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
@@ -653,6 +961,50 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir"); 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] #[test]
fn truncates_large_instruction_content_for_rendering() { fn truncates_large_instruction_content_for_rendering() {
let rendered = render_instruction_content(&"x".repeat(4500)); let rendered = render_instruction_content(&"x".repeat(4500));
@@ -876,6 +1228,51 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
#[test]
fn load_system_prompt_respects_rules_import_config() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
fs::write(
root.join(".claw").join("settings.json"),
r#"{"rulesImport":"none"}"#,
)
.expect("write settings");
let _guard = env_lock();
ensure_valid_cwd();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(
&root,
"2026-03-31",
"linux",
"6.8",
ModelFamilyIdentity::Claude,
)
.expect("system prompt should load")
.join("\n\n");
std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claw_home {
std::env::set_var("CLAW_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAW_CONFIG_HOME");
}
assert!(!prompt.contains("cursor rule"));
assert!(prompt.contains("rulesImport"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn renders_default_claude_model_family_identity() { fn renders_default_claude_model_family_identity() {
// given: a prompt builder without an explicit model family override // given: a prompt builder without an explicit model family override
@@ -945,7 +1342,7 @@ mod tests {
assert!(prompt.contains("# System")); assert!(prompt.contains("# System"));
assert!(prompt.contains("# Project context")); assert!(prompt.contains("# Project context"));
assert!(prompt.contains("# Claude instructions")); assert!(prompt.contains("# Project instructions"));
assert!(prompt.contains("Project rules")); assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode")); assert!(prompt.contains("permissionMode"));
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY)); assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
@@ -990,7 +1387,7 @@ mod tests {
path: PathBuf::from("/tmp/project/CLAUDE.md"), path: PathBuf::from("/tmp/project/CLAUDE.md"),
content: "Project rules".to_string(), content: "Project rules".to_string(),
}]); }]);
assert!(rendered.contains("# Claude instructions")); assert!(rendered.contains("# Project instructions"));
assert!(rendered.contains("scope: /tmp/project")); assert!(rendered.contains("scope: /tmp/project"));
assert!(rendered.contains("Project rules")); 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> { pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
let path = path.as_ref(); let path = path.as_ref();
let snapshot = self.render_jsonl_snapshot()?; let snapshot = self.render_jsonl_snapshot()?;
rotate_session_file_if_needed(path)?; // #112: wrap ENOENT during rotate as concurrent modification
write_atomic(path, &snapshot)?; 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)?; cleanup_rotated_logs(path)?;
Ok(()) Ok(())
} }

View File

@@ -28,7 +28,8 @@ pub struct SessionStore {
impl SessionStore { impl SessionStore {
/// Build a store from the server's current working directory. /// 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> { pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
let cwd = cwd.as_ref(); let cwd = cwd.as_ref();
// #151: canonicalize so equivalent paths (symlinks, relative vs // #151: canonicalize so equivalent paths (symlinks, relative vs
@@ -40,7 +41,6 @@ impl SessionStore {
.join(".claw") .join(".claw")
.join("sessions") .join("sessions")
.join(workspace_fingerprint(&canonical_cwd)); .join(workspace_fingerprint(&canonical_cwd));
fs::create_dir_all(&sessions_root)?;
Ok(Self { Ok(Self {
sessions_root, sessions_root,
workspace_root: canonical_cwd, workspace_root: canonical_cwd,
@@ -49,7 +49,8 @@ impl SessionStore {
/// Build a store from an explicit `--data-dir` flag. /// 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`. /// where `<workspace_hash>` is derived from `workspace_root`.
pub fn from_data_dir( pub fn from_data_dir(
data_dir: impl AsRef<Path>, data_dir: impl AsRef<Path>,
@@ -64,7 +65,6 @@ impl SessionStore {
.as_ref() .as_ref()
.join("sessions") .join("sessions")
.join(workspace_fingerprint(&canonical_workspace)); .join(workspace_fingerprint(&canonical_workspace));
fs::create_dir_all(&sessions_root)?;
Ok(Self { Ok(Self {
sessions_root, sessions_root,
workspace_root: canonical_workspace, workspace_root: canonical_workspace,
@@ -93,8 +93,19 @@ impl SessionStore {
} }
pub fn resolve_reference(&self, reference: &str) -> Result<SessionHandle, SessionControlError> { 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) { if is_session_reference_alias(reference) {
let latest = self.latest_session()?; let latest = self.latest_session_excluding(exclude_id)?;
return Ok(SessionHandle { return Ok(SessionHandle {
id: latest.id, id: latest.id,
path: latest.path, path: latest.path,
@@ -158,12 +169,45 @@ impl SessionStore {
} }
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> { 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); 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); 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( Err(SessionControlError::Format(format_no_managed_sessions(
&self.sessions_root, &self.sessions_root,
))) )))
@@ -204,18 +248,34 @@ impl SessionStore {
&self, &self,
reference: &str, reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> { ) -> Result<LoadedManagedSession, SessionControlError> {
match self.load_session(reference) { self.load_session_excluding(reference, None)
Ok(loaded) => Ok(loaded), }
Err(SessionControlError::WorkspaceMismatch { expected, actual })
if is_session_reference_alias(reference) => /// Like `load_session_loose` but also excludes a session by ID.
{ /// Used by /resume latest to skip the current empty session and find
let handle = self.resolve_reference(reference)?; /// 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)?; 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)
{
eprintln!( eprintln!(
" Note: resuming session from a different workspace (origin: {})", " Note: resuming session from a different workspace (origin: {})",
actual.display() actual.display()
); );
let _ = expected; // suppress unused warning }
} else {
self.validate_loaded_session(&handle.path, &session)?;
}
Ok(LoadedManagedSession { Ok(LoadedManagedSession {
handle: SessionHandle { handle: SessionHandle {
id: session.session_id.clone(), id: session.session_id.clone(),
@@ -224,9 +284,6 @@ impl SessionStore {
session, session,
}) })
} }
Err(other) => Err(other),
}
}
pub fn fork_session( pub fn fork_session(
&self, &self,
@@ -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( fn format_legacy_session_missing_workspace_root(
session_path: &Path, session_path: &Path,
workspace_root: &Path, workspace_root: &Path,
@@ -760,14 +827,21 @@ mod tests {
use crate::session::Session; use crate::session::Session;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf { fn temp_dir() -> PathBuf {
let nanos = SystemTime::now() let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.expect("time should be after epoch") .expect("time should be after epoch")
.as_nanos(); .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 { 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] #[test]
fn session_store_from_cwd_isolates_sessions_by_workspace() { fn session_store_from_cwd_isolates_sessions_by_workspace() {
// given // given
@@ -1181,6 +1287,114 @@ mod tests {
fs::remove_dir_all(base).expect("temp dir should clean up"); 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] #[test]
fn session_exists_and_delete_are_scoped_to_workspace_store() { fn session_exists_and_delete_are_scoped_to_workspace_store() {
// given // given

View File

@@ -12,7 +12,6 @@ path = "src/main.rs"
[dependencies] [dependencies]
api = { path = "../api" } api = { path = "../api" }
commands = { path = "../commands" } commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" }
crossterm = "0.28" crossterm = "0.28"
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
rustyline = "15" rustyline = "15"

View File

@@ -1,10 +1,9 @@
use std::env; use std::env;
use std::process::Command; use std::process::Command;
fn main() { fn command_output(program: &str, args: &[&str]) -> Option<String> {
// Get git SHA (short hash) Command::new(program)
let git_sha = Command::new("git") .args(args)
.args(["rev-parse", "--short", "HEAD"])
.output() .output()
.ok() .ok()
.and_then(|output| { .and_then(|output| {
@@ -14,11 +13,37 @@ fn main() {
None 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={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()); let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=TARGET={target}"); println!("cargo:rustc-env=TARGET={target}");
@@ -35,23 +60,12 @@ fn main() {
}) })
.or_else(|| std::env::var("BUILD_DATE").ok()) .or_else(|| std::env::var("BUILD_DATE").ok())
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Fall back to current date via `date` command command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string())
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())
}); });
println!("cargo:rustc-env=BUILD_DATE={build_date}"); println!("cargo:rustc-env=BUILD_DATE={build_date}");
// Rerun if git state changes // 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/HEAD");
println!("cargo:rerun-if-changed=.git/refs"); 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!( const STARTER_CLAW_JSON: &str = concat!(
"{\n", "{\n",
" \"permissions\": {\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",
"}\n", "}\n",
); );
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
pub(crate) enum InitStatus { pub(crate) enum InitStatus {
Created, Created,
Updated, Updated,
Partial,
Deferred,
Skipped, Skipped,
} }
@@ -24,6 +33,8 @@ impl InitStatus {
match self { match self {
Self::Created => "created", Self::Created => "created",
Self::Updated => "updated", Self::Updated => "updated",
Self::Partial => "partial (created missing sub-files)",
Self::Deferred => "deferred (created on first session save)",
Self::Skipped => "skipped (already exists)", Self::Skipped => "skipped (already exists)",
} }
} }
@@ -36,6 +47,8 @@ impl InitStatus {
match self { match self {
Self::Created => "created", Self::Created => "created",
Self::Updated => "updated", Self::Updated => "updated",
Self::Partial => "partial",
Self::Deferred => "deferred",
Self::Skipped => "skipped", 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 mut artifacts = Vec::new();
let claw_dir = cwd.join(".claw"); 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 { artifacts.push(InitArtifact {
name: ".claw/", 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"); let claw_json = cwd.join(".claw.json");
@@ -414,11 +448,26 @@ mod tests {
concat!( concat!(
"{\n", "{\n",
" \"permissions\": {\n", " \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n", " \"defaultMode\": \"acceptEdits\"\n",
" }\n", " }\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"); let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json")); assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/")); assert!(gitignore.contains(".claw/sessions/"));
@@ -436,14 +485,24 @@ mod tests {
fs::create_dir_all(&root).expect("create root"); 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("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore"); 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"); let first = initialize_repo(&root).expect("first init should succeed");
assert!(first assert!(first
.render() .render()
.contains("CLAUDE.md skipped (already exists)")); .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 = initialize_repo(&root).expect("second init should succeed");
let second_rendered = second.render(); let second_rendered = second.render();
assert!(second_rendered.contains(".claw/")); 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(".claw.json"));
assert!(second_rendered.contains("skipped (already exists)")); assert!(second_rendered.contains("skipped (already exists)"));
assert!(second_rendered.contains(".gitignore skipped (already exists)")); assert!(second_rendered.contains(".gitignore skipped (already exists)"));
@@ -474,16 +533,22 @@ mod tests {
created_names, created_names,
vec![ vec![
".claw/".to_string(), ".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(), ".claw.json".to_string(),
".gitignore".to_string(), ".gitignore".to_string(),
"CLAUDE.md".to_string(), "CLAUDE.md".to_string(),
], ],
"fresh init should place all four artifacts in created[]" "fresh init should place created artifacts in created[]"
); );
assert!( assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(), fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts" "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 second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped); let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
@@ -491,28 +556,39 @@ mod tests {
skipped_names, skipped_names,
vec![ vec![
".claw/".to_string(), ".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(), ".claw.json".to_string(),
".gitignore".to_string(), ".gitignore".to_string(),
"CLAUDE.md".to_string(), "CLAUDE.md".to_string(),
], ],
"idempotent init should place all four artifacts in skipped[]" "idempotent init should place existing artifacts in skipped[]"
); );
assert!( assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(), second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts" "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 // artifact_json_entries() uses the machine-stable `json_tag()` which
// never changes wording (unlike `label()` which says "skipped (already exists)"). // never changes wording (unlike `label()` which says "skipped (already exists)").
let entries = second.artifact_json_entries(); let entries = second.artifact_json_entries();
assert_eq!(entries.len(), 4); assert_eq!(entries.len(), 6);
for entry in &entries { 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(); let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
if name == ".claw/sessions/" {
assert_eq!(status, "deferred");
} else {
assert_eq!( assert_eq!(
status, "skipped", status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'" "machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
); );
} }
}
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
#![allow(clippy::while_let_on_iterator)] #![allow(clippy::while_let_on_iterator)]
use std::fs; use std::fs;
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Output, Stdio}; use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -246,8 +247,121 @@ stderr:
} }
#[test] #[test]
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() { fn prompt_subcommand_reads_prompt_from_stdin_when_no_positional_arg_423() {
let workspace = unique_temp_dir("compact-nontty-json-help"); let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("prompt-stdin-423");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
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");
let prompt = format!("{SCENARIO_PREFIX}streaming_text\n");
let output = run_claw_with_stdin(
&workspace,
&config_home,
&home,
&base_url,
&[
"prompt",
"--output-format",
"json",
"--compact",
"--permission-mode",
"read-only",
"--model",
"sonnet",
],
&prompt,
);
assert!(
output.status.success(),
"prompt stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout should parse");
assert_eq!(
parsed["message"],
"Mock streaming says hello from the parity harness."
);
let captured = runtime.block_on(server.captured_requests());
assert!(
captured
.iter()
.any(|request| request.raw_body.contains("PARITY_SCENARIO:streaming_text")),
"stdin prompt should reach the provider request: {captured:?}"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn prompt_subcommand_stdin_flag_appends_pipe_context_423() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("prompt-stdin-flag-423");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
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");
let prompt_context = format!("{SCENARIO_PREFIX}streaming_text\n");
let output = run_claw_with_stdin(
&workspace,
&config_home,
&home,
&base_url,
&[
"prompt",
"Use stdin context",
"--stdin",
"--output-format",
"json",
"--compact",
"--permission-mode",
"read-only",
"--model",
"sonnet",
],
&prompt_context,
);
assert!(
output.status.success(),
"prompt --stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let captured = runtime.block_on(server.captured_requests());
let provider_body = captured
.iter()
.find(|request| request.raw_body.contains("Use stdin context"))
.expect("merged prompt should reach provider");
assert!(
provider_body
.raw_body
.contains("PARITY_SCENARIO:streaming_text"),
"merged prompt should include stdin context: {provider_body:?}"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
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 config_home = workspace.join("config-home");
let home = workspace.join("home"); let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist"); fs::create_dir_all(&workspace).expect("workspace should exist");
@@ -258,19 +372,19 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
&workspace, &workspace,
&config_home, &config_home,
&home, &home,
&["compact", "--output-format", "json", "--help"], &["compact", "--output-format", "json"],
Duration::from_secs(2), Duration::from_secs(2),
); );
assert!( assert!(
!output.status.success(), !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 // #819/#820/#823: JSON abort envelopes route to stdout
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8"); let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!( assert!(
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'), 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 stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = let parsed: Value =
@@ -356,6 +470,39 @@ fn run_claw(
command.output().expect("claw should launch") command.output().expect("claw should launch")
} }
fn run_claw_with_stdin(
cwd: &std::path::Path,
config_home: &std::path::Path,
home: &std::path::Path,
base_url: &str,
args: &[&str],
stdin: &str,
) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(cwd)
.env_clear()
.env("ANTHROPIC_API_KEY", "test-compact-key")
.env("ANTHROPIC_BASE_URL", base_url)
.env("CLAW_CONFIG_HOME", config_home)
.env("HOME", home)
.env("NO_COLOR", "1")
.env("PATH", "/usr/bin:/bin")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.args(args)
.spawn()
.expect("claw should launch");
child
.stdin
.as_mut()
.expect("stdin should be piped")
.write_all(stdin.as_bytes())
.expect("stdin should write");
child.stdin.take();
child.wait_with_output().expect("output should collect")
}
fn run_claw_closed_stdin_with_timeout( fn run_claw_closed_stdin_with_timeout(
cwd: &std::path::Path, cwd: &std::path::Path,
config_home: &std::path::Path, config_home: &std::path::Path,

File diff suppressed because it is too large Load Diff

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"))); 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] #[test]
fn resumed_status_command_emits_structured_json_when_requested() { fn resumed_status_command_emits_structured_json_when_requested() {
// given // given
@@ -268,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert_eq!(parsed["kind"], "status"); assert_eq!(parsed["kind"], "status");
// model is null in resume mode (not known without --model flag) // model is null in resume mode (not known without --model flag)
assert!(parsed["model"].is_null()); 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_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number()); assert!(parsed["usage"]["turns"].is_number());
assert!(parsed["workspace"]["cwd"].as_str().is_some()); 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["version"].as_str().is_some());
assert!(parsed["git_sha"].as_str().is_some()); assert!(parsed["git_sha"].as_str().is_some());
assert!(parsed["target"].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] #[test]
@@ -460,8 +530,9 @@ fn resumed_help_command_emits_structured_json() {
let stdout = String::from_utf8(output.stdout).expect("utf8"); let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json"); let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "help"); assert_eq!(parsed["kind"], "help");
assert!(parsed["text"].as_str().is_some()); // #338: resume help now uses 'message' field for parity with top-level help
let text = parsed["text"].as_str().unwrap(); assert!(parsed["message"].as_str().is_some());
let text = parsed["message"].as_str().unwrap();
assert!(text.contains("/status"), "help text should list /status"); assert!(text.contains("/status"), "help text should list /status");
} }

View File

@@ -201,30 +201,20 @@ impl GlobalToolRegistry {
return Ok(None); return Ok(None);
} }
let builtin_specs = mvp_tool_specs(); let actual_names = self.actual_tool_names();
let canonical_names = builtin_specs let canonical_names = self.canonical_allowed_tool_names();
.iter() let canonical_name_set = canonical_names.iter().cloned().collect::<BTreeSet<_>>();
.map(|spec| spec.name.to_string()) let mut name_map = BTreeMap::new();
.chain( for actual in &actual_names {
self.plugin_tools let canonical = canonical_allowed_tool_name(actual);
.iter() name_map.insert(allowed_tool_lookup_key(actual), canonical.clone());
.map(|tool| tool.definition().name.clone()), name_map.insert(allowed_tool_lookup_key(&canonical), canonical);
) }
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [ for (alias, canonical) in self.allowed_tool_aliases() {
("read", "read_file"), if canonical_name_set.contains(&canonical) {
("write", "write_file"), name_map.insert(allowed_tool_lookup_key(&alias), canonical);
("edit", "edit_file"), }
("glob", "glob_search"),
("grep", "grep_search"),
] {
name_map.insert(alias.to_string(), canonical.to_string());
} }
let mut allowed = BTreeSet::new(); let mut allowed = BTreeSet::new();
@@ -233,11 +223,11 @@ impl GlobalToolRegistry {
.split(|ch: char| ch == ',' || ch.is_whitespace()) .split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty()) .filter(|token| !token.is_empty())
{ {
let normalized = normalize_tool_name(token); let canonical = name_map.get(&allowed_tool_lookup_key(token)).ok_or_else(|| {
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!( format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})", "invalid_tool_name: unsupported tool in --allowedTools: {token}\nAvailable: {}\nAliases: {}\nHint: Use canonical snake_case tool names from Available or aliases from Aliases.",
canonical_names.join(", ") canonical_names.join(", "),
format_allowed_tool_aliases(&self.allowed_tool_aliases())
) )
})?; })?;
allowed.insert(canonical.clone()); allowed.insert(canonical.clone());
@@ -258,7 +248,10 @@ impl GlobalToolRegistry {
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> { pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
let builtin = mvp_tool_specs() let builtin = mvp_tool_specs()
.into_iter() .into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) .filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.map(|spec| ToolDefinition { .map(|spec| ToolDefinition {
name: spec.name.to_string(), name: spec.name.to_string(),
description: Some(spec.description.to_string()), description: Some(spec.description.to_string()),
@@ -267,7 +260,11 @@ impl GlobalToolRegistry {
let runtime = self let runtime = self
.runtime_tools .runtime_tools
.iter() .iter()
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str()))) .filter(|tool| {
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(&tool.name))
})
})
.map(|tool| ToolDefinition { .map(|tool| ToolDefinition {
name: tool.name.clone(), name: tool.name.clone(),
description: tool.description.clone(), description: tool.description.clone(),
@@ -277,8 +274,11 @@ impl GlobalToolRegistry {
.plugin_tools .plugin_tools
.iter() .iter()
.filter(|tool| { .filter(|tool| {
allowed_tools allowed_tools.is_none_or(|allowed| {
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) allowed.contains(&canonical_allowed_tool_name(
tool.definition().name.as_str(),
))
})
}) })
.map(|tool| ToolDefinition { .map(|tool| ToolDefinition {
name: tool.definition().name.clone(), name: tool.definition().name.clone(),
@@ -294,19 +294,29 @@ impl GlobalToolRegistry {
) -> Result<Vec<(String, PermissionMode)>, String> { ) -> Result<Vec<(String, PermissionMode)>, String> {
let builtin = mvp_tool_specs() let builtin = mvp_tool_specs()
.into_iter() .into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) .filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.map(|spec| (spec.name.to_string(), spec.required_permission)); .map(|spec| (spec.name.to_string(), spec.required_permission));
let runtime = self let runtime = self
.runtime_tools .runtime_tools
.iter() .iter()
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str()))) .filter(|tool| {
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(&tool.name))
})
})
.map(|tool| (tool.name.clone(), tool.required_permission)); .map(|tool| (tool.name.clone(), tool.required_permission));
let plugin = self let plugin = self
.plugin_tools .plugin_tools
.iter() .iter()
.filter(|tool| { .filter(|tool| {
allowed_tools allowed_tools.is_none_or(|allowed| {
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) allowed.contains(&canonical_allowed_tool_name(
tool.definition().name.as_str(),
))
})
}) })
.map(|tool| { .map(|tool| {
permission_mode_from_plugin(tool.required_permission()) permission_mode_from_plugin(tool.required_permission())
@@ -316,6 +326,52 @@ impl GlobalToolRegistry {
Ok(builtin.chain(runtime).chain(plugin).collect()) Ok(builtin.chain(runtime).chain(plugin).collect())
} }
#[must_use]
pub fn actual_tool_names(&self) -> Vec<String> {
mvp_tool_specs()
.iter()
.map(|spec| spec.name.to_string())
.chain(
self.plugin_tools
.iter()
.map(|tool| tool.definition().name.clone()),
)
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
.collect()
}
#[must_use]
pub fn canonical_allowed_tool_names(&self) -> Vec<String> {
self.actual_tool_names()
.into_iter()
.map(|name| canonical_allowed_tool_name(&name))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[must_use]
pub fn allowed_tool_aliases(&self) -> BTreeMap<String, String> {
let mut aliases = BTreeMap::from([
("read".to_string(), "read_file".to_string()),
("Read".to_string(), "read_file".to_string()),
("write".to_string(), "write_file".to_string()),
("Write".to_string(), "write_file".to_string()),
("edit".to_string(), "edit_file".to_string()),
("Edit".to_string(), "edit_file".to_string()),
("glob".to_string(), "glob_search".to_string()),
("Glob".to_string(), "glob_search".to_string()),
("grep".to_string(), "grep_search".to_string()),
("Grep".to_string(), "grep_search".to_string()),
]);
for actual in self.actual_tool_names() {
let canonical = canonical_allowed_tool_name(&actual);
if actual != canonical {
aliases.insert(actual, canonical);
}
}
aliases
}
#[must_use] #[must_use]
pub fn has_runtime_tool(&self, name: &str) -> bool { pub fn has_runtime_tool(&self, name: &str) -> bool {
self.runtime_tools.iter().any(|tool| tool.name == name) self.runtime_tools.iter().any(|tool| tool.name == name)
@@ -378,8 +434,40 @@ impl GlobalToolRegistry {
} }
} }
fn normalize_tool_name(value: &str) -> String { pub fn canonical_allowed_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase() let trimmed = value.trim().replace('-', "_");
let mut output = String::new();
let chars = trimmed.chars().collect::<Vec<_>>();
for (index, ch) in chars.iter().copied().enumerate() {
if ch == '_' || ch.is_whitespace() {
output.push('_');
continue;
}
let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
let next = chars.get(index + 1).copied();
if ch.is_ascii_uppercase()
&& index > 0
&& !output.ends_with('_')
&& (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
|| next.is_some_and(|n| n.is_ascii_lowercase()))
{
output.push('_');
}
output.push(ch.to_ascii_lowercase());
}
output.trim_matches('_').to_string()
}
fn allowed_tool_lookup_key(value: &str) -> String {
canonical_allowed_tool_name(value).replace('_', "")
}
fn format_allowed_tool_aliases(aliases: &BTreeMap<String, String>) -> String {
aliases
.iter()
.map(|(alias, canonical)| format!("{alias}={canonical}"))
.collect::<Vec<_>>()
.join(", ")
} }
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> { fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
@@ -514,7 +602,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["url", "prompt"], "required": ["url", "prompt"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly, required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "WebSearch", name: "WebSearch",
@@ -535,7 +623,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"], "required": ["query"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly, required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "TodoWrite", name: "TodoWrite",
@@ -1225,13 +1313,14 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
}, },
ToolSpec { ToolSpec {
name: "GitShow", name: "GitShow",
description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.", description: "Show a commit, tag, or tree object. Use format to control output: patch (default) shows the full diff, stat shows a diffstat summary, and metadata shows commit info without the diff. Supports showing a specific file at a commit (commit:path) for patch/stat output. Use this instead of running git show via bash to get structured output.",
input_schema: json!({ input_schema: json!({
"type": "object", "type": "object",
"properties": { "properties": {
"commit": { "type": "string" }, "commit": { "type": "string" },
"path": { "type": "string" }, "path": { "type": "string" },
"stat": { "type": "boolean" } "stat": { "type": "boolean" },
"format": { "type": "string", "enum": ["patch", "stat", "metadata"] },
}, },
"required": ["commit"], "required": ["commit"],
"additionalProperties": false "additionalProperties": false
@@ -1320,8 +1409,26 @@ fn execute_tool_with_enforcer(
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?; maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
run_grep_search(grep_input) run_grep_search(grep_input)
} }
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch), "WebFetch" => {
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search), let web_input = from_value::<WebFetchInput>(input)?;
maybe_enforce_permission_check_with_mode(
enforcer,
name,
input,
PermissionMode::DangerFullAccess,
)?;
run_web_fetch(web_input)
}
"WebSearch" => {
let web_input = from_value::<WebSearchInput>(input)?;
maybe_enforce_permission_check_with_mode(
enforcer,
name,
input,
PermissionMode::DangerFullAccess,
)?;
run_web_search(web_input)
}
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write), "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
"Skill" => from_value::<SkillInput>(input).and_then(run_skill), "Skill" => from_value::<SkillInput>(input).and_then(run_skill),
"Agent" => from_value::<AgentInput>(input).and_then(run_agent), "Agent" => from_value::<AgentInput>(input).and_then(run_agent),
@@ -2008,14 +2115,37 @@ fn run_git_log(input: GitLogInput) -> Result<String, String> {
} }
} }
#[allow(clippy::needless_pass_by_value)]
/// Execute `git show` for a given commit, optionally with --stat or a file path. /// Execute `git show` for a given commit, optionally with --stat or a file path.
/// Uses the `commit:path` syntax when a path is specified. /// Uses the `commit:path` syntax when a path is specified.
fn run_git_show(input: GitShowInput) -> Result<String, String> { fn run_git_show(input: GitShowInput) -> Result<String, String> {
let mut args: Vec<String> = vec!["show".to_string()]; let mut args: Vec<String> = vec!["show".to_string()];
if input.stat.unwrap_or(false) {
match input.format.as_deref() {
Some("metadata") if input.path.is_some() => {
return Err(
"GitShow format \"metadata\" cannot be combined with path; metadata describes a commit, not a blob. Use format \"patch\" or \"stat\" with path, or omit path."
.to_string(),
);
}
Some("metadata") => {
args.push("--format=medium".to_string());
args.push("--no-patch".to_string());
}
Some("stat") => {
args.push("--stat".to_string()); args.push("--stat".to_string());
} }
Some("patch") | None => {
if input.format.is_none() && input.stat.unwrap_or(false) {
args.push("--stat".to_string());
}
}
Some(other) => {
return Err(format!(
"unknown GitShow format: \"{other}\". Supported values: \"patch\" (default), \"stat\", \"metadata\"."
));
}
}
if let Some(ref path) = input.path { if let Some(ref path) = input.path {
args.push(format!("{}:{}", input.commit, path)); args.push(format!("{}:{}", input.commit, path));
} else { } else {
@@ -2571,6 +2701,20 @@ fn is_within_workspace(path: &str) -> bool {
let path = PathBuf::from(trimmed); 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, check if it starts with CWD
if path.is_absolute() { if path.is_absolute() {
if let Ok(cwd) = std::env::current_dir() { if let Ok(cwd) = std::env::current_dir() {
@@ -2588,6 +2732,26 @@ fn run_powershell(input: PowerShellInput) -> Result<String, String> {
to_pretty_json(execute_powershell(input).map_err(|error| error.to_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> { fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
} }
@@ -2964,6 +3128,9 @@ struct GitShowInput {
#[serde(default)] #[serde(default)]
/// If true, show diffstat summary instead of full diff. /// If true, show diffstat summary instead of full diff.
stat: Option<bool>, stat: Option<bool>,
#[serde(default)]
/// Output format: "patch" (default) shows the full diff, "stat" shows a diffstat summary, and "metadata" shows commit info without the diff. When set, takes priority over `stat`.
format: Option<String>,
} }
/// Input for the GitBlame tool: shows per-line author/revision info for a file. /// Input for the GitBlame tool: shows per-line author/revision info for a file.
@@ -4165,7 +4332,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"PowerShell", "PowerShell",
], ],
}; };
tools.into_iter().map(str::to_string).collect() tools.into_iter().map(canonical_allowed_tool_name).collect()
} }
fn agent_permission_policy() -> PermissionPolicy { fn agent_permission_policy() -> PermissionPolicy {
@@ -5193,7 +5360,10 @@ impl SubagentToolExecutor {
impl ToolExecutor for SubagentToolExecutor { impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> { fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if !self.allowed_tools.contains(tool_name) { if !self
.allowed_tools
.contains(&canonical_allowed_tool_name(tool_name))
{
return Err(ToolError::new(format!( return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent" "tool `{tool_name}` is not enabled for this sub-agent"
))); )));
@@ -5208,7 +5378,10 @@ impl ToolExecutor for SubagentToolExecutor {
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> { fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs() mvp_tool_specs()
.into_iter() .into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) .filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.collect() .collect()
} }
@@ -6779,6 +6952,87 @@ mod tests {
assert!(names.contains(&"WorkerSendPrompt")); assert!(names.contains(&"WorkerSendPrompt"));
} }
#[test]
fn git_show_schema_exposes_format_enum() {
let spec = mvp_tool_specs()
.into_iter()
.find(|spec| spec.name == "GitShow")
.expect("GitShow spec");
assert_eq!(
spec.input_schema["properties"]["format"]["enum"],
json!(["patch", "stat", "metadata"])
);
}
#[test]
fn git_show_supports_patch_stat_metadata_and_rejects_metadata_path() {
let _guard = env_guard();
let root = temp_path("git-show-format");
init_git_repo(&root);
commit_file(&root, "README.md", "initial\nupdated\n", "update readme");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&root).expect("set cwd");
let patch = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "patch"}))
.expect("patch git show");
let patch: serde_json::Value = serde_json::from_str(&patch).expect("patch json");
assert!(patch["output"]
.as_str()
.expect("patch output")
.contains("diff --git"));
let stat = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "stat"}))
.expect("stat git show");
let stat: serde_json::Value = serde_json::from_str(&stat).expect("stat json");
assert!(stat["output"]
.as_str()
.expect("stat output")
.contains("README.md"));
let legacy_stat = execute_tool("GitShow", &json!({"commit": "HEAD", "stat": true}))
.expect("legacy stat git show");
let legacy_stat: serde_json::Value =
serde_json::from_str(&legacy_stat).expect("legacy stat json");
assert!(legacy_stat["output"]
.as_str()
.expect("legacy stat output")
.contains("README.md"));
let metadata = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "metadata"}))
.expect("metadata git show");
let metadata: serde_json::Value = serde_json::from_str(&metadata).expect("metadata json");
let metadata_output = metadata["output"].as_str().expect("metadata output");
assert!(metadata_output.contains("commit "));
assert!(metadata_output.contains("update readme"));
assert!(!metadata_output.contains("diff --git"));
let file_patch = execute_tool(
"GitShow",
&json!({"commit": "HEAD", "path": "README.md", "format": "patch"}),
)
.expect("file patch git show");
let file_patch: serde_json::Value =
serde_json::from_str(&file_patch).expect("file patch json");
assert_eq!(
file_patch["output"].as_str().expect("file patch output"),
"initial\nupdated"
);
let metadata_path = execute_tool(
"GitShow",
&json!({"commit": "HEAD", "path": "README.md", "format": "metadata"}),
)
.expect_err("metadata with path should be rejected");
assert!(metadata_path.contains("cannot be combined with path"));
let invalid = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "bogus"}))
.expect_err("invalid format should be rejected");
assert!(invalid.contains("unknown GitShow format"));
std::env::set_current_dir(&previous).expect("restore cwd");
let _ = fs::remove_dir_all(root);
}
#[test] #[test]
fn rejects_unknown_tool_names() { fn rejects_unknown_tool_names() {
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected"); let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
@@ -7477,6 +7731,29 @@ mod tests {
} }
} }
#[test]
fn allowed_tools_normalize_to_canonical_snake_case_and_aliases_432() {
let registry = GlobalToolRegistry::builtin();
let allowed = registry
.normalize_allowed_tools(&["Read,WebFetch,MCP".to_string()])
.expect("aliases and legacy names should normalize")
.expect("allow-list should be populated");
assert!(allowed.contains("read_file"));
assert!(allowed.contains("web_fetch"));
assert!(allowed.contains("mcp"));
assert!(!allowed.contains("Read"));
assert!(!allowed.contains("WebFetch"));
let canonical = registry.canonical_allowed_tool_names();
assert!(canonical.contains(&"web_fetch".to_string()));
assert!(canonical.contains(&"todo_write".to_string()));
assert!(!canonical.contains(&"WebFetch".to_string()));
assert_eq!(
registry.allowed_tool_aliases().get("WebFetch"),
Some(&"web_fetch".to_string())
);
}
#[test] #[test]
fn runtime_tools_extend_registry_definitions_permissions_and_search() { fn runtime_tools_extend_registry_definitions_permissions_and_search() {
let registry = GlobalToolRegistry::builtin() let registry = GlobalToolRegistry::builtin()
@@ -8458,7 +8735,7 @@ mod tests {
.expect("spawn job should be captured"); .expect("spawn job should be captured");
assert_eq!(captured_job.prompt, "Check tests and outstanding work."); assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
assert!(captured_job.allowed_tools.contains("read_file")); assert!(captured_job.allowed_tools.contains("read_file"));
assert!(!captured_job.allowed_tools.contains("Agent")); assert!(!captured_job.allowed_tools.contains("agent"));
let normalized = execute_tool( let normalized = execute_tool(
"Agent", "Agent",
@@ -9058,7 +9335,7 @@ mod tests {
let general = allowed_tools_for_subagent("general-purpose"); let general = allowed_tools_for_subagent("general-purpose");
assert!(general.contains("bash")); assert!(general.contains("bash"));
assert!(general.contains("write_file")); assert!(general.contains("write_file"));
assert!(!general.contains("Agent")); assert!(!general.contains("agent"));
let explore = allowed_tools_for_subagent("Explore"); let explore = allowed_tools_for_subagent("Explore");
assert!(explore.contains("read_file")); assert!(explore.contains("read_file"));
@@ -9066,13 +9343,13 @@ mod tests {
assert!(!explore.contains("bash")); assert!(!explore.contains("bash"));
let plan = allowed_tools_for_subagent("Plan"); let plan = allowed_tools_for_subagent("Plan");
assert!(plan.contains("TodoWrite")); assert!(plan.contains("todo_write"));
assert!(plan.contains("StructuredOutput")); assert!(plan.contains("structured_output"));
assert!(!plan.contains("Agent")); assert!(!plan.contains("agent"));
let verification = allowed_tools_for_subagent("Verification"); let verification = allowed_tools_for_subagent("Verification");
assert!(verification.contains("bash")); assert!(verification.contains("bash"));
assert!(verification.contains("PowerShell")); assert!(verification.contains("power_shell"));
assert!(!verification.contains("write_file")); assert!(!verification.contains("write_file"));
} }
@@ -10156,6 +10433,26 @@ printf 'pwsh:%s' "$1"
); );
} }
#[test]
fn given_workspace_write_enforcer_when_web_tools_then_denied() {
let registry = workspace_write_registry();
for (tool, input) in [
(
"WebFetch",
json!({"url":"https://example.com", "prompt":"summarize"}),
),
("WebSearch", json!({"query":"rust language"})),
] {
let err = registry
.execute(tool, &input)
.expect_err("network tools should require explicit full access");
assert!(
err.contains("requires 'danger-full-access'"),
"{tool} should require elevated mode: {err}"
);
}
}
#[test] #[test]
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() { fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
let registry = workspace_write_registry(); let registry = workspace_write_registry();

145
scripts/dogfood-probe.py Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@dataclass(frozen=True)
class ProbeResult:
kind: str
argv: list[str]
returncode: int | None
stdout: bytes
stderr: bytes
message: str | None = None
@property
def stdout_text(self) -> str:
return self.stdout.decode('utf-8', errors='replace')
@property
def stderr_text(self) -> str:
return self.stderr.decode('utf-8', errors='replace')
def to_json_dict(self) -> dict[str, object]:
return {
'kind': self.kind,
'argv': self.argv,
'returncode': self.returncode,
'stdout': self.stdout_text,
'stderr': self.stderr_text,
'message': self.message,
}
def run_probe(argv: Sequence[str], *, timeout: float = 10.0, require_stdout_json_byte0: bool = False) -> ProbeResult:
explicit_argv = [str(arg) for arg in argv]
if not explicit_argv:
return ProbeResult(
kind='probe_error',
argv=[],
returncode=None,
stdout=b'',
stderr=b'',
message='argv must contain at least the executable path',
)
try:
completed = subprocess.run(
explicit_argv,
capture_output=True,
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
return ProbeResult(
kind='timeout',
argv=explicit_argv,
returncode=None,
stdout=exc.stdout or b'',
stderr=exc.stderr or b'',
message=f'probe timed out after {timeout:g}s',
)
except (OSError, ValueError) as exc:
return ProbeResult(
kind='probe_error',
argv=explicit_argv,
returncode=None,
stdout=b'',
stderr=b'',
message=str(exc),
)
if require_stdout_json_byte0:
if not completed.stdout:
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message='stdout is empty; expected JSON at byte 0',
)
if completed.stdout[:1] not in (b'{', b'['):
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message='stdout JSON does not start at byte 0',
)
try:
json.loads(completed.stdout.decode('utf-8'))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message=f'stdout is not parseable JSON: {exc}',
)
if completed.returncode != 0:
return ProbeResult(
kind='product_error',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
message=f'process exited with code {completed.returncode}',
)
return ProbeResult(
kind='ok',
argv=explicit_argv,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description='Run an argv-safe dogfood probe and emit separated channels as JSON.')
parser.add_argument('--timeout', type=float, default=10.0)
parser.add_argument('--stdout-json-byte0', action='store_true', help='Require stdout to be parseable JSON starting at byte 0.')
parser.add_argument('command', nargs=argparse.REMAINDER, help='Executable and arguments to run. Use -- before the target argv.')
args = parser.parse_args(argv)
command = args.command
if command and command[0] == '--':
command = command[1:]
result = run_probe(command, timeout=args.timeout, require_stdout_json_byte0=args.stdout_json_byte0)
print(json.dumps(result.to_json_dict(), sort_keys=True))
return 0 if result.kind == 'ok' else 1
if __name__ == '__main__':
raise SystemExit(main())

0
tests/__init__.py Normal file
View File

View File

@@ -9,6 +9,9 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh' NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py'
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]: def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
@@ -21,6 +24,16 @@ def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedPr
) )
def run_dogfood_probe(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
['python3', str(DOGFOOD_PROBE), *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
class RoadmapHelperTests(unittest.TestCase): class RoadmapHelperTests(unittest.TestCase):
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None: def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
@@ -46,6 +59,17 @@ class RoadmapHelperTests(unittest.TestCase):
self.assertIn('999', result.stderr) self.assertIn('999', result.stderr)
self.assertNotIn('1000', result.stdout) self.assertNotIn('1000', result.stdout)
def test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
roadmap = Path(temp_dir) / 'missing-ROADMAP.md'
result = run_next_id(roadmap)
self.assertNotEqual(0, result.returncode)
self.assertEqual('', result.stdout)
self.assertIn('ROADMAP not found', result.stderr)
self.assertIn(str(roadmap), result.stderr)
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None: def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
script_dir = Path(temp_dir) / 'scripts' script_dir = Path(temp_dir) / 'scripts'
@@ -62,6 +86,78 @@ class RoadmapHelperTests(unittest.TestCase):
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr) self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
self.assertIn('refusing to print a next id', result.stderr) self.assertIn('refusing to print a next id', result.stderr)
def test_dogfood_probe_runs_explicit_argv_and_separates_channels(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
fixture = Path(temp_dir) / 'fixture.py'
fixture.write_text(
'from __future__ import annotations\n'
'import json\n'
'import sys\n'
'print(json.dumps({"argv": sys.argv[1:]}))\n'
'print("diagnostic", file=sys.stderr)\n'
)
result = run_dogfood_probe([
'--stdout-json-byte0',
'--',
'python3',
str(fixture),
'--output-format',
'json',
'doctor',
'--help',
])
self.assertEqual(0, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('ok', payload['kind'])
self.assertEqual([
'python3',
str(fixture),
'--output-format',
'json',
'doctor',
'--help',
], payload['argv'])
self.assertEqual(0, payload['returncode'])
self.assertEqual('{"argv": ["--output-format", "json", "doctor", "--help"]}\n', payload['stdout'])
self.assertEqual('diagnostic\n', payload['stderr'])
def test_dogfood_probe_labels_timeout_separately_from_product_error(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
fixture = Path(temp_dir) / 'sleep.py'
fixture.write_text('import time\ntime.sleep(2)\n')
result = run_dogfood_probe(['--timeout', '0.1', '--', 'python3', str(fixture)])
self.assertEqual(1, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('timeout', payload['kind'])
self.assertIsNone(payload['returncode'])
self.assertIn('timed out', payload['message'])
def test_dogfood_probe_labels_probe_construction_failure(self) -> None:
result = run_dogfood_probe([])
self.assertEqual(1, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('probe_error', payload['kind'])
self.assertEqual([], payload['argv'])
self.assertIsNone(payload['returncode'])
self.assertIn('argv must contain', payload['message'])
def test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
fixture = Path(temp_dir) / 'prefixed.py'
fixture.write_text('print("warning before json")\nprint("{}")\n')
result = run_dogfood_probe(['--stdout-json-byte0', '--', 'python3', str(fixture)])
self.assertEqual(1, result.returncode)
payload = __import__('json').loads(result.stdout)
self.assertEqual('product_error', payload['kind'])
self.assertEqual(0, payload['returncode'])
self.assertIn('byte 0', payload['message'])
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()