From 2ab2f44e1d07007f8c48f117b9ba580b757624b0 Mon Sep 17 00:00:00 2001 From: bellman Date: Thu, 4 Jun 2026 00:50:17 +0900 Subject: [PATCH] fix: keep session help local --- ROADMAP.md | 2 +- rust/crates/rusty-claude-cli/src/main.rs | 47 ++++++- .../tests/output_format_contract.rs | 127 ++++++++++++++++++ 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 21371aec..d1a4e5a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6357,7 +6357,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 426. **DONE — environment model selection is validated and status exposes alias/env provenance** — fixed 2026-06-03 in `fix: validate env model selection`. `CLAW_MODEL`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_MODEL` now share the same env-model path before config/default fallback; prompt/REPL startup validates the resolved model before provider construction; and `status --output-format json` reports invalid env/config models as `status:"warn"` with `model_validation_error_kind:"invalid_model"` while preserving workspace/config/sandbox context. Status JSON now includes `model_alias_resolved_to` and `model_env_var`, making alias expansion and the winning env var auditable. The built-in/default `opus` alias now targets `anthropic/claude-opus-4-7` / `claude-opus-4-7`, with docs updated in `USAGE.md` and `rust/README.md`; the API alias table keeps token-limit metadata for both `claude-opus-4-7` and legacy `claude-opus-4-6`. Regression coverage: `status_json_accepts_namespaced_model_env_and_surfaces_alias_426`, `status_json_warns_on_invalid_model_env_426`, model alias/unit tests, and provider alias tests. -427. **Subcommand `--help` paths (`resume`, `session`, `compact`) hit the auth gate and trigger config validation before returning static help — `claw resume --help` with no credentials returns `missing_credentials` error instead of help text** — dogfooded 2026-05-11 by Jobdori on `1fecdf09` in response to Clawhip pinpoint nudge at `1503252843669491892`. Reproduction (no env vars, isolated `CLAW_CONFIG_HOME`): `claw resume --help` returns `{"error":"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY..."}` instead of usage text. Same for `claw session --help`, `claw compact --help`. By contrast, `claw prompt --help` and `claw --help` (top-level) return proper usage text without auth. Even worse: with a broken `.claw.json` discovered up the parent directory tree (e.g., `mcpServers.missing-command: missing string field command`), the subcommand `--help` paths fail with `[error-kind: unknown]` from config validation — config load is happening before `--help` is parsed. **Sibling exit-code bug:** `claw resume --help --output-format json` returns `kind:"missing_credentials"` but exits **0** (the exit-code parity bug from #422 reproduces on this path too — only `cli_parse` exits 1 consistently). **Sibling: `claw resume ` should be local-only** but also hits `missing_credentials` — `resume` of a session that doesn't exist on disk should return `kind:"session_not_found"` from a local lookup, not require API credentials. Same class as ROADMAP #357 (session list requires creds) and #369 (session help/fork require credentials) — now confirmed for `resume`. **Required fix shape:** (a) `--help` MUST short-circuit before any auth check, config load, or session resolution — emit static usage text from a compiled-in string table, no I/O; (b) `resume ` must check the local session store first; if the id is absent on disk, emit `kind:"session_not_found"` with `sessions_dir` field; only require auth when resuming a known-on-disk session that requires re-establishing API context; (c) ensure exit code 1 for all error envelopes including `missing_credentials` returned from a `--help` path that should never have reached the auth gate; (d) regression test: with empty `CLAW_CONFIG_HOME` and no env vars, every `claw --help` returns usage text on stdout, exit 0, no `kind:*_error` envelope. **Why this matters:** `--help` is the universal CLI discovery primitive. Failing `--help` because of missing API credentials or broken config files makes claw undiscoverable to users debugging an already-broken setup. Cross-references #357 (session list), #369 (session help/fork), #422 (exit code parity), #108 (subcommand fallthrough). Source: Jobdori live dogfood, `1fecdf09`, 2026-05-11. +427. **DONE — resume/session/compact help short-circuits locally and missing resume sessions report the session store** — fixed 2026-06-03 in `fix: keep session help local`. `claw resume --help`, `claw --resume --help`, `claw session --help`, and `claw compact --help` now route through static `LocalHelpTopic` output before config loading, session resolution, credential checks, provider startup, or slash-command interactive-only fallthrough. The direct `claw resume ` alias now shares the existing `--resume` restore parser, so `claw resume --output-format json` returns a local `session_not_found` restore envelope with `sessions_dir` and exit code 1 instead of reaching provider credentials. Regression coverage: `resume_session_compact_help_short_circuits_before_config_or_auth_427`, `resume_missing_session_json_reports_local_store_before_auth_427`, local help parser tests, and resume parser tests. 428. **Default `permission_mode` is `danger-full-access` — claw runs with FULL filesystem + network + tool access out of the box, with no opt-in flag and no warning from `doctor`** — dogfooded 2026-05-11 by Jobdori on `72048449` in response to Clawhip pinpoint nudge at `1503260393622212628`. Reproduction (no env vars, isolated `CLAW_CONFIG_HOME`, no config files, no CLI flags): `claw status --output-format json` returns `permission_mode:"danger-full-access"` as the default. The three supported modes per the validator error message are `read-only`, `workspace-write`, `danger-full-access` — and `danger-full-access` is chosen with zero user opt-in. `claw doctor --output-format json` produces a `sandbox` check with `status:"warn", summary:"sandbox was requested but is not currently active"` (because macOS lacks Linux `unshare`), but **emits no warning, info, or summary about the permission_mode itself being danger-full-access**. There is no `permissions` check in `doctor` output at all. **Required fix shape:** (a) change default `permission_mode` to `workspace-write` (safe-by-default: filesystem write limited to cwd, network limited to LLM endpoints, no arbitrary command exec); (b) require explicit `--permission-mode danger-full-access` or `--dangerously-skip-permissions` to opt into full access; (c) add a `permissions` check to `doctor --output-format json` that emits `status:"warn"` when `permission_mode == "danger-full-access"` without explicit source (flag/env/config), with details like `mode:"danger-full-access", source:"default", message:"running with full access without explicit opt-in"`; (d) document the three modes and the default in USAGE.md with one-paragraph descriptions of what each mode allows. **Sibling typed-error bug:** `claw --permission-mode bogus-mode status --output-format json` returns `kind:"unknown"` instead of `kind:"invalid_permission_mode"` — same catch-all problem as #424, #426. **Sibling flag-name asymmetry:** `--dangerously-skip-permissions` works but `--skip-permissions` (Claude Code's flag) returns `kind:"cli_parse"` `unknown option`. Users migrating from Claude Code lose the short flag name. **Why this matters:** every other security-conscious CLI (Docker, kubectl, terraform) requires explicit opt-in for dangerous modes. Defaulting to `danger-full-access` is a footgun for first-time users who pipe `curl install.sh | sh` and immediately get a tool with full filesystem write and arbitrary command exec. The doctor surface is the only diagnostic users consult before trusting the tool, and it stays silent about the most permissive setting. Cross-references #50, #87, #91, #94, #97, #101, #106, #115, #123 (permission-audit sweep) — those all cover permission *rule* and *list* surfaces; #428 covers the *mode default* itself. Source: Jobdori live dogfood, `72048449`, 2026-05-11. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 50f9fce1..b950e38f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -870,6 +870,9 @@ enum LocalHelpTopic { // `claw --help` has one consistent contract. Init, State, + Resume, + Session, + Compact, Export, Version, SystemPrompt, @@ -1132,6 +1135,10 @@ fn parse_args(args: &[String]) -> Result { "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "state" => Some(LocalHelpTopic::State), + "resume" => Some(LocalHelpTopic::Resume), + "session" => Some(LocalHelpTopic::Session), + "compact" => Some(LocalHelpTopic::Compact), + "--resume" => Some(LocalHelpTopic::Resume), "export" => Some(LocalHelpTopic::Export), "version" => Some(LocalHelpTopic::Version), "system-prompt" => Some(LocalHelpTopic::SystemPrompt), @@ -1218,11 +1225,14 @@ fn parse_args(args: &[String]) -> Result { allow_broad_cwd, }); } + if let Some(action) = parse_local_help_action(&rest, output_format) { + return action; + } if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..], output_format); } - if let Some(action) = parse_local_help_action(&rest, output_format) { - return action; + if rest.first().map(String::as_str) == Some("resume") { + return parse_resume_args(&rest[1..], output_format); } // #696: `claw compact` is the bare name of the interactive `/compact` // slash command, not a prompt. When extra args such as `--help` appear @@ -1580,6 +1590,9 @@ fn parse_local_help_action( "system-prompt" => LocalHelpTopic::SystemPrompt, "dump-manifests" => LocalHelpTopic::DumpManifests, "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, + "resume" | "--resume" => LocalHelpTopic::Resume, + "session" => LocalHelpTopic::Session, + "compact" => LocalHelpTopic::Compact, "model" | "models" => LocalHelpTopic::Model, "settings" => LocalHelpTopic::Settings, _ => return None, @@ -1642,6 +1655,9 @@ fn parse_single_word_command_alias( "system-prompt" => Some(LocalHelpTopic::SystemPrompt), "dump-manifests" => Some(LocalHelpTopic::DumpManifests), "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + "resume" => Some(LocalHelpTopic::Resume), + "session" => Some(LocalHelpTopic::Session), + "compact" => Some(LocalHelpTopic::Compact), "agents" | "agent" => Some(LocalHelpTopic::Agents), "skills" | "skill" => Some(LocalHelpTopic::Skills), "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), @@ -1693,6 +1709,9 @@ fn parse_single_word_command_alias( "system-prompt" => Some(LocalHelpTopic::SystemPrompt), "dump-manifests" => Some(LocalHelpTopic::DumpManifests), "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + "resume" => Some(LocalHelpTopic::Resume), + "session" => Some(LocalHelpTopic::Session), + "compact" => Some(LocalHelpTopic::Compact), "agents" | "agent" => Some(LocalHelpTopic::Agents), "skills" | "skill" => Some(LocalHelpTopic::Skills), "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), @@ -3652,6 +3671,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu // #787: fall back to kind-derived hint when message has no \n delimiter let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); + let sessions_dir = sessions_dir().ok().map(|path| path.display().to_string()); // #819: JSON mode resume errors go to stdout for parity with other // non-interactive command guards. println!( @@ -3664,6 +3684,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu "error": short_reason, "exit_code": 1, "hint": hint, + "sessions_dir": sessions_dir, }) ); } else { @@ -8161,6 +8182,25 @@ fn render_help_topic(topic: LocalHelpTopic) -> String { Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise Related claw status · ROADMAP #139 (this worker-concept contract)" .to_string(), + LocalHelpTopic::Resume => format!( + "Resume\n Usage claw resume [session-path|session-id|{LATEST_SESSION_REFERENCE}] [/slash-command ...] [--output-format ]\n Alias claw --resume [session-path|session-id|{LATEST_SESSION_REFERENCE}]\n Purpose restore or inspect a saved session without starting a new provider turn\n Output session restore or resume-safe command output; missing sessions return session_not_found\n Formats text (default), json\n Related /resume · /session list · claw --resume {LATEST_SESSION_REFERENCE} /status" + ), + LocalHelpTopic::Session => "Session + Usage claw session --help [--output-format ] + Purpose show /session command guidance without loading config, credentials, or a session + Actions list · exists · switch · fork · delete + Direct use run /session in the REPL or claw --resume SESSION.jsonl /session + Formats text (default), json + Related claw resume · claw export · .claw/sessions/" + .to_string(), + LocalHelpTopic::Compact => "Compact + Usage claw compact --help [--output-format ] + Purpose show compaction guidance without loading config, credentials, or a session + Direct use run /compact in the REPL or claw --resume SESSION.jsonl /compact + Output compaction removes older tool-detail messages when the selected session is large enough + Formats text (default), json + Related claw resume · /compact · /status" + .to_string(), LocalHelpTopic::Export => "Export Usage claw export [--session ] [--output ] [--output-format ] Purpose serialize a managed session to JSON for review, transfer, or archival @@ -8256,6 +8296,9 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { LocalHelpTopic::Acp => "acp", LocalHelpTopic::Init => "init", LocalHelpTopic::State => "state", + LocalHelpTopic::Resume => "resume", + LocalHelpTopic::Session => "session", + LocalHelpTopic::Compact => "compact", LocalHelpTopic::Export => "export", LocalHelpTopic::Version => "version", LocalHelpTopic::SystemPrompt => "system-prompt", diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 8d71e2ff..8c51e834 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -124,6 +124,133 @@ fn doctor_help_text_stays_plaintext_and_local_702() { serde_json::from_str::(&stdout).expect_err("text help should remain plaintext"); } +#[test] +fn resume_session_compact_help_short_circuits_before_config_or_auth_427() { + let root = unique_temp_dir("session-help-local-427"); + let config_home = root.join("config-home"); + let home = root.join("home"); + fs::create_dir_all(root.join(".claw")).expect("project config dir should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write(root.join(".claw").join("settings.json"), "{").expect("broken config should write"); + + 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", ""), + ]; + + let text_cases: &[(&[&str], &str)] = &[ + (&["resume", "--help"], "Resume\n"), + (&["--resume", "--help"], "Resume\n"), + (&["session", "--help"], "Session\n"), + (&["compact", "--help"], "Compact\n"), + ]; + for (args, heading) in text_cases { + let output = run_claw(&root, args, &envs); + assert!( + output.status.success(), + "{args:?} should exit 0 before auth/config; stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.starts_with(heading), "{args:?} stdout: {stdout}"); + assert!(stdout.contains("Usage"), "{args:?} stdout: {stdout}"); + assert!( + !stdout.contains("missing_credentials") && !stderr.contains("missing_credentials"), + "{args:?} must not hit provider auth: stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + !stdout.contains("config_parse_error") && stderr.is_empty(), + "{args:?} must not load broken config: stdout={stdout:?} stderr={stderr:?}" + ); + serde_json::from_str::(&stdout).expect_err("text help should remain plaintext"); + } + + let json_cases: &[(&[&str], &str)] = &[ + (&["resume", "--help", "--output-format", "json"], "resume"), + (&["--resume", "--help", "--output-format", "json"], "resume"), + (&["session", "--help", "--output-format", "json"], "session"), + (&["compact", "--help", "--output-format", "json"], "compact"), + ]; + for (args, topic) in json_cases { + let parsed = assert_json_command_with_env(&root, args, &envs); + assert_eq!(parsed["kind"], "help", "{args:?}: {parsed}"); + assert_eq!(parsed["status"], "ok", "{args:?}: {parsed}"); + assert_eq!(parsed["topic"], *topic, "{args:?}: {parsed}"); + assert!( + parsed["message"] + .as_str() + .is_some_and(|message| message.contains("Usage")), + "{args:?} should include static usage text: {parsed}" + ); + } +} + +#[test] +fn resume_missing_session_json_reports_local_store_before_auth_427() { + let root = unique_temp_dir("resume-missing-local-427"); + let config_home = root.join("config-home"); + let home = root.join("home"); + fs::create_dir_all(&root).expect("temp dir should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + + 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", ""), + ]; + + let output = run_claw( + &root, + &[ + "resume", + "definitely-missing-session", + "--output-format", + "json", + ], + &envs, + ); + assert_eq!( + output.status.code(), + Some(1), + "missing session should exit 1" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.is_empty(), + "JSON missing-session stderr should be empty: {stderr:?}" + ); + assert!( + !stdout.contains("missing_credentials") && !stderr.contains("missing_credentials"), + "missing session must not reach provider auth: stdout={stdout:?} stderr={stderr:?}" + ); + let parsed: Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("resume missing session must emit JSON, got: {stdout:?}")); + assert_eq!(parsed["error_kind"], "session_not_found", "{parsed}"); + assert_eq!(parsed["action"], "restore", "{parsed}"); + assert!( + parsed["sessions_dir"] + .as_str() + .is_some_and(|path| path.contains(".claw") && path.contains("sessions")), + "missing-session JSON should expose the searched sessions_dir: {parsed}" + ); +} + #[test] fn version_emits_json_when_requested() { let root = unique_temp_dir("version-json");