diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d40836e5b..d24782fb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,14 @@ jobs: AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL: '1' run: sh src/testing/scenarios/fresh-alpha-install.sh ./binaries/autohand-test ci-alpha-smoke + - name: Verify Git Bash shell execution + if: runner.os == 'Windows' + shell: bash + timeout-minutes: 10 + run: | + cygpath --version + bun run test:ci tests/windowsShell.integration.test.ts tests/ui/ink/TeamPanel.test.tsx + - name: Verify binary (Unix) if: runner.os != 'Windows' run: | @@ -209,3 +217,30 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Windows --help smoke test failed with exit code $LASTEXITCODE" } + + windows-tuistory: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build CLI for terminal tests + run: bun run build + + - name: Verify native team recovery from Git Bash + shell: bash + run: bun run test:tuistory tests/tuistory/team-activity.tuistory.test.ts diff --git a/.github/workflows/installer-release-compatibility.yml b/.github/workflows/installer-release-compatibility.yml new file mode 100644 index 000000000..d776821a3 --- /dev/null +++ b/.github/workflows/installer-release-compatibility.yml @@ -0,0 +1,27 @@ +name: Installer release compatibility +on: + pull_request: + paths: + - install.sh + - install.ps1 + - scripts/install-computer-use.mjs + - src/testing/scenarios/released-installer-smoke.py + - .github/workflows/installer-release-compatibility.yml + schedule: + - cron: '21 6 * * *' + workflow_dispatch: +permissions: + contents: read +jobs: + released-installer: + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + channel: [stable, alpha] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - name: Install the independently published binary in a clean HOME + run: python3 src/testing/scenarios/released-installer-smoke.py --channel '${{ matrix.channel }}' diff --git a/CHANGELOG.md b/CHANGELOG.md index e45140b77..4eab776a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Added + +- Translate supported Claude Code user and project permission rules during import, with a dry-run preview, and remove raw settings and environment data left by earlier imports. + +### Fixed + +- Keep npm installation successful when optional Computer control setup fails, matching the standalone installers. + +- Restore the interactive composer after session export dialogs so typing and a clean exit remain available. + ### Changed - Persistent goals (`/goal`, `/goals` and `--goal`) are enabled by default for 0.9.9. Existing explicit settings are preserved; `/experiments disable slash_goal` turns them off. diff --git a/README.md b/README.md index 64354d59f..6b5f2da4f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Interactive sessions assess repository readiness in the background while you wor The shell installer includes [Zit](https://getzit.org) v0.1.2 by default on supported macOS and Linux platforms, preserving an existing installation and verifying release checksums. Use `--no-zit` or `AUTOHAND_SKIP_ZIT_INSTALL=1` to opt out. The Windows installer supports `-NoZit` and the same environment variable; it preserves an existing Zit installation and reports that native Windows binaries are currently unavailable. `autohand --zit` requires Git 2.38 or newer. Installing Zit does not initialize repositories. +On Windows, running `install.sh` from Git Bash, MSYS, or Cygwin delegates to the native PowerShell installer and preserves installation options. Autohand launched from Git Bash uses Bash for command tools; see [shell settings](docs/config-reference.md#shell-settings) to choose an executable explicitly. + The npm package includes checksum-verified Zit binaries for macOS and Linux on ARM64 and x64. `npm install -g autohand-cli` and `bun install -g autohand-cli` download them with the package, including when lifecycle scripts are disabled. Autohand uses `ZIT_BIN`, an existing `zit` on `PATH`, or its packaged binary, in that order. Rust and a separate Zit installation are not required. Native Windows Zit binaries are currently unavailable. - **Terminal-Native Agent**: Understands your codebase and executes approved changes from the CLI @@ -107,7 +109,8 @@ bun add -g . ### Requirements -- Bun ≥1.0 (`curl -fsSL https://bun.sh/install | bash`) +- Node.js 22 or later (to run Autohand) +- Bun 1.0 or later, only to build from source (`curl -fsSL https://bun.sh/install | bash`) - Git (for version control features) - ripgrep (optional, for faster search) @@ -600,6 +603,13 @@ Create `~/.autohand/config.json` or use `config.toml`, `config.yaml`, or `config | llama.cpp | `llamacpp` | Local inference | | MLX | `mlx` | Apple Silicon optimized | | Z.ai | `zai` | High-performance inference | +| Anthropic | `anthropic` | Claude models with your own API key | +| Azure OpenAI | `azure` | Azure deployments via API key, Entra ID or managed identity | +| Vertex AI | `vertexai` | Google models on Vertex AI | +| xAI | `xai` | Grok models via API key or OAuth | +| Cerebras | `cerebras` | Hosted fast inference | +| NVIDIA | `nvidia` | NVIDIA-hosted models | +| Sakana AI | `sakana` | Sakana AI models | ## Session Management @@ -803,7 +813,7 @@ We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) ### Common Issues -**Installation fails**: Make sure you have Bun installed and up to date. +**Installation fails**: Make sure Node.js 22 or later is installed. Building from source also needs Bun. **Permission denied**: Check your file permissions and try running with appropriate privileges. diff --git a/docs/cloud-blog-draft.md b/docs/cloud-blog-draft.md index 4faef798d..7c8b9e0c3 100644 --- a/docs/cloud-blog-draft.md +++ b/docs/cloud-blog-draft.md @@ -4,7 +4,7 @@ Editorial draft. Keep unpublished until CLI and Desktop release readiness and na Start a hosted agent from the terminal, close the terminal while it works, and reconnect to the saved conversation. Autohand Cloud adds `--cloud`, `/cloud`, `/handoff`, and `/open` to the CLI. The same session can be reviewed in Code Web and an updated Autohand Desktop app. -Cloud CLI sessions are available only to Pro, Pro+, and Max accounts. Your selected account still needs active billing, model capacity, and permission to operate resources. A paid plan does not grant access to a repository that you have not authorized. +Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Team and Enterprise need CLI 0.9.10 or later. Your selected account still needs active billing, model capacity, and permission to operate resources. A paid plan does not grant access to a repository that you have not authorized. ## Start a task where you work @@ -83,7 +83,7 @@ Desktop links carry only a session ID and account ID. The remote cloud window ha The native CLI route authenticates a single account credential and forwards only allowlisted operations through the existing hosted API. It rechecks membership, ownership, write permissions, plan eligibility, model capacity, and repository grants. Browser cookies mixed with native bearer credentials are rejected. Responses remain private and are not cached. -Only Pro, Pro+, and Max can admit new cloud work through this interface. Owned stop and queue cleanup remain available after a downgrade. If durable Jobs are unavailable, admission fails instead of falling back to execution tied to the terminal request. +Only these plans can start new cloud work. Owned stop and queue cleanup remain available after a downgrade. If durable Jobs are unavailable, admission fails instead of falling back to execution tied to the terminal request. Local `--yes`, `--yolo`, custom MCP settings, system prompts, and tool-scope overrides cannot bypass hosted policy. Unsupported combinations fail before cloud launch. Local restrictions that Cloud cannot represent also fail closed. Read-only plan mode can preserve deny rules limited to mutating workspace operations; other custom restrictions require local execution. diff --git a/docs/cloud.md b/docs/cloud.md index f9c37f6cd..479f723f1 100644 --- a/docs/cloud.md +++ b/docs/cloud.md @@ -1,7 +1,7 @@ # Autohand Cloud from your terminal -Cloud CLI sessions are available only to **Pro, Pro+, and Max** accounts. Free, -Lite, Team, and Enterprise accounts cannot start cloud work through this interface. +Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Team and Enterprise need CLI 0.9.10 or later. +Free and Lite accounts cannot start cloud work through this interface. The selected account also needs write access, active billing, available model capacity, and repository authorization. Sign in with `autohand login`. diff --git a/docs/config-reference.md b/docs/config-reference.md index acf759e22..ac76b9180 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -1683,7 +1683,7 @@ git add -A && git commit -m "feat: add user dashboard with charts" ## Shell Settings -Control what shell commands started by Autohand can see. This covers the `run_command` tool, `!` terminal commands, and streaming or interactive shells. Hook commands and MCP server processes have their own launchers and are not affected. +Choose the shell used by `run_command`, foreground and background `!` commands, and streaming shells, and control their environment. Hook commands and MCP server processes have their own launchers and are not affected. ```json { @@ -1700,6 +1700,8 @@ Control what shell commands started by Autohand can see. This covers the `run_co | Field | Type | Default | Description | | ------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `executable` | string | auto | Shell executable or full path, such as `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh.exe`, or `/bin/zsh` | +| `dialect` | string | inferred | `posix`, `cmd`, or `powershell`; requires `executable`. Controls command invocation and the syntax described to the agent | | `env.inherit` | string | `all` | `all` passes the whole parent environment; `essential` keeps PATH, HOME, USER, SHELL, TMPDIR, locale and terminal variables (plus their Windows equivalents); `none` starts empty | | `env.include` | string[] | `[]` | Variable names or globs to add back on top of the inherited set | | `env.exclude` | string[] | `[]` | Variable names or globs removed after inheritance and includes | @@ -1707,6 +1709,8 @@ Control what shell commands started by Autohand can see. This covers the `run_co Variables Autohand needs to run its own tooling (`AUTOHAND_*`, `AUTOHAND_CLI`, `AUTOHAND_HOME`, `CODEX_HOME`) are always present. Explicit per-command overrides supplied by a tool call still win over the policy. +By default, macOS/Linux use `SHELL` or `/bin/sh`. Native Windows uses `ComSpec` or `cmd.exe`. When launched from Git Bash (`MSYSTEM` is set), Autohand resolves its Bash executable with `cygpath.exe` and uses POSIX command syntax. If that conversion fails, configure the full Windows path to `bash.exe`. Bug reports identify the selected command shell and dialect separately from the parent shell. + ## Network Settings ```json @@ -2411,6 +2415,7 @@ Configure MCP (Model Context Protocol) servers to extend Autohand with external | `headers` | `Record` | No | `{}` | Custom HTTP headers for http/sse transport (e.g. auth tokens) | | `env` | `Record` | No | `{}` | Environment variables passed to the server | | `autoConnect` | `boolean` | No | `true` | Whether to auto-connect on startup | +| `localOnly` | `boolean` | No | `false` | Keep the server on this device; do not publish it to the account control plane | > Servers connect asynchronously in the background during startup without blocking the prompt. Use `/mcp` to manage servers interactively, or `/mcp add` to browse the community registry or add custom servers. @@ -2564,6 +2569,18 @@ Only compatible command hooks are translated. Unsupported source HTTP, prompt/ag See [hook import compatibility](./hooks.md#import-hooks-from-another-coding-agent) for source file locations, event mappings, timeout defaults, supported responses, and manual-porting limits. +### Import MCP Servers from Claude Code + +```sh +autohand import claude --categories mcp +``` + +The importer reads user servers from `~/.claude.json`, current-project servers from `projects[].mcpServers` in that file, and project servers from `/.mcp.json`. Use `--path ` when importing for a workspace other than the current directory. + +Stdio servers keep their `command`, `args`, and `env`; HTTP and SSE servers keep their `url` and `headers`. User servers are added to the selected user config. Project servers are added to `/.autohand/settings.local.json`. Imported servers are saved with `autoConnect: false`, so review them before enabling them. Existing servers with the same name are preserved and reported as skipped (`already exists`). + +Environment values and headers are copied as configured. The importer does not print server values, and imported servers stay on this device instead of being published to the account control plane. + --- ## Chrome Extension Settings @@ -2956,6 +2973,10 @@ These flags override config file settings: | `--patch` | Generate git patch without applying changes | | `--output ` | Output file for patch (used with --patch) | +When a `--worktree` session ends, Autohand removes its worktree only when there are no uncommitted or untracked files and no commits missing from the original branch. A branch created for that session is also removed; pre-existing branches are retained. Otherwise it prints `Kept worktree (branch ) with changes` so you can recover your work. This cleanup applies to interactive, command, patch, RPC and ACP sessions; tmux and Zit behavior is unchanged. + +The agent's `exit_worktree` tool follows the same preservation rule and returns to the original workspace. `keep: true` always keeps the worktree. `discard_changes: true` explicitly requests removal with changes and follows the deletion approval policy; the restricted client profile denies it. `keep` takes precedence when both options are set. Cleanup stops safely if Git cannot verify the worktree or original branch. + ### Zit Sessions `--zit` is the counterpart of `--worktree` for repositories shared by several agents through [Zit](https://github.com/autohandai/getzit). Instead of a git worktree and branch, the session runs in a disposable copy of the repository's current Zit state, and its edits are recorded as one Zit change when the session ends. @@ -3471,6 +3492,6 @@ Fantail ignores this setting; local sessions do not advertise it. `search.provider` accepts `autohand`, `browser-profile`, `exa`, `brave`, `parallel`, `google`, and `duckduckgo`. An explicit `--search-engine` wins over the saved setting in interactive, patch, automatic, RPC, and ACP modes. -When no provider is selected, a signed-in account with server-verified paid access uses Autohand Web Search. Lite, Pro, Pro Plus, Max, Team, and Enterprise include this service through `https://dev.autohand.ai/api/native/web-search`; Free users configure an existing provider during standard or quick setup, or with `/search`. Queries are limited to 1,024 characters, 10 results, and 30 requests per account per minute. Access uses the selected Autohand account and existing account credentials; customers do not need Cloudflare credentials. A failed hosted search reports its status and does not change providers. +When no provider is selected, a signed-in account with server-verified access uses Autohand Web Search. Verified Free accounts include 10 web searches a day, shared with Code Web for the same account and reset at 00:00 UTC. `/search` shows the remaining allowance. At the cap, the tool returns the upgrade link and reset time, and the CLI prints that notice once per session. Lite, Pro, Pro Plus, Max, Team, and Enterprise retain their included search access. Queries are limited to 1,024 characters, 10 results, and 30 native requests per account per minute. Access uses the selected Autohand account and existing account credentials; customers do not need Cloudflare credentials. A failed hosted search reports its status and does not change providers. -Browser Profile is the local default. Connect Chromium through `/browser` or provide an available Chrome/Brave profile. A missing, locked, or blocked profile produces a setup error instead of silently switching to Google. Exa, Brave, and Parallel require `search.exaApiKey`, `search.braveApiKey`, or `search.parallelApiKey`, respectively, or `EXA_API_KEY`, `BRAVE_SEARCH_API_KEY`, or `PARALLEL_API_KEY`. Onboarding never saves a keyed provider with a blank key. Google and DuckDuckGo can be selected explicitly; automated requests may receive CAPTCHA challenges. +Browser Profile is the local default. Connect Chromium through `/browser` or provide an available Chrome/Brave profile. Before launching the profile, Autohand checks its `SingletonLock`, including Chromium's symlink lock. With Chrome open, search uses the connected Chrome bridge first, then eligible Autohand Web Search, then one line directing you to `/search`. A locked profile with no fallback is not offered to the model. A closed profile keeps the existing browser search path. Exa, Brave, and Parallel require `search.exaApiKey`, `search.braveApiKey`, or `search.parallelApiKey`, respectively, or `EXA_API_KEY`, `BRAVE_SEARCH_API_KEY`, or `PARALLEL_API_KEY`. Onboarding never saves a keyed provider with a blank key. Google and DuckDuckGo can be selected explicitly; automated requests may receive CAPTCHA challenges. diff --git a/docs/features.md b/docs/features.md index 9e14f3838..02f172a2a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -39,7 +39,7 @@ Autohand is an autonomous LLM-powered coding agent designed to work directly in - [ ] Search history and command palette ## Session Management -- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff for Pro, Pro+, and Max +- [x] [Cloud sessions](cloud.md): hosted execution, reconnect, queue steering, approvals, and local/cloud handoff. Needs Pro, Pro+, Max, Team or Enterprise (Team and Enterprise from CLI 0.9.10). - [x] Auto-save to `~/.autohand/sessions` - [x] Resume with `/resume` or `autohand resume ` - [x] History tracking (interactions, tool outputs, agent thoughts) diff --git a/docs/import.md b/docs/import.md new file mode 100644 index 000000000..307a99e0c --- /dev/null +++ b/docs/import.md @@ -0,0 +1,9 @@ +# Import Claude Code permissions + +From 0.9.9, `autohand import claude --categories settings` translates supported Claude Code allow and deny rules into active Autohand settings. Run the same command with `--dry-run` first to see the mapping without writing files. + +User rules come from `~/.claude/settings.json` and merge into your Autohand config. For each repository recorded in Claude Code's project registry or session metadata, plus the current repository, `.claude/settings.json` and `.claude/settings.local.json` merge into that repository's `.autohand/settings.local.json`. Existing settings and rules remain, duplicate rules are not added, and deny rules take precedence over allows. + +`Bash(npm test:*)` becomes `run_command:npm test*` and the corresponding `shell` rule. Literal simple shell commands can use these grants. Commands with shell operators, quoting, substitutions, redirection or unsupported execution wrappers need separate approval; when command denies cannot be evaluated safely, those commands stay denied. Read rules map to `read_file`; Edit and Write map to `write_file`. Exact `./path` and `./path/**` patterns (trailing `/**` only) are supported. More complex globs and domain-scoped WebFetch rules are listed as `skipped (no equivalent)` instead of guessed. Exact MCP tool names map to the same tool. + +The importer never copies Claude's raw settings or environment block. On your next non-dry-run Claude import, it scrubs any legacy `imported-claude-settings.json` containing raw settings or environment data and reports the cleanup. A dry run only previews that cleanup. diff --git a/docs/providers.md b/docs/providers.md index 56b94a748..d27766171 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -75,7 +75,7 @@ Bundled provider model choices live in `src/providers/models.json` and packaged Autohand AI is the preferred first-party provider. Use `autohandai` with `plan: "cloud"` for Autohand-hosted Fantail and Moa models at `https://api.autohand.ai/v1`, or `plan: "local"` for Apple Silicon MLX local inference. -The catalogue also carries Weka with `cliSupported: false`. This lets the CLI describe the model without putting it in setup, `/model`, team-model, ACP, or RPC selection. Weka accepts structured `state` and `questions` at `POST https://api.autohand.ai/v1/decisions`; use it from the API or Console Playground. +Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Call it at `POST https://api.autohand.ai/v1/decisions`. Fantail uses a 256k input context window and a 16k maximum output. Moa retains its 1M input context and 262,144-token output contract. These limits and the available cloud model list are read from `src/providers/models.json` (or a validated catalog override), not duplicated in provider code. If a selected model requires a higher account tier or an Autohand AI request quota is exhausted, the CLI identifies the exhausted window, shows its exact reset in the computer's local timezone with a relative duration, and includes the trusted upgrade link returned by the inference service. With Autohand AI active, `/usage` and the `/status` Usage tab show the same 5-hour, 24-hour, weekly, and monthly quota windows before they are exhausted, alongside request, uncached-input-token, and output-token throughput. Pro provides 1K requests, 500K uncached input tokens, and 80K output tokens per minute; Max provides exactly four times each limit. diff --git a/docs/teams.md b/docs/teams.md index 698c55519..37cd368a4 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -77,6 +77,10 @@ transport cannot be confused with terminal output. | `/team create [name]` | Create a team or reuse the active team with the same name | | `/team status` | Show team name, active/completed status, member count, task progress, member list with status indicators, and the full task list | | `/team view` | Open the live team view without asking the agent for a status report | +| `/team errors` | Inspect failed task IDs, errors, and available partial output | +| `/team retry ` | Retry one failed task on the active team | +| `/team retry-failed` | Retry failed tasks while preserving successful and cancelled tasks | +| `/team hide` | Hide the team panel and keep workers available | | `/team shutdown` | Gracefully shut down all teammates | Example `/team status` output: @@ -179,13 +183,17 @@ Open or close the expanded view with: - **Ctrl+T** as the portable terminal shortcut. - **`/team view`** to open it explicitly. -The expanded `TeamPanel` renders below the fixed status line and above the composer, and shows: +The footer shows the shortcut from your current keybindings. The expanded team view takes over the composer while it is open, and shows: -- **Header** -- Team name with an active/inactive indicator. +- **Header** -- Team name and whether workers are available or the team is closed. - **Task list** -- Progress count (N/M done) and each task with its status icon, subject, and assigned owner. - **Teammate list** -- Each teammate with a status icon, name, agent type, and its effective provider and model while it is working. +Use the arrow keys to select a task and Enter to inspect its error and available output. Press Esc to return to the list. Press **r** to retry the selected failed task, **a** to retry all failed tasks, or **s** to shut down the team; each action asks for confirmation. Press **h**, Esc from the list, or the displayed team shortcut to hide the view. Hiding keeps workers running and available for reuse; shutdown stops them. + +When no tasks remain pending or in progress, the footer stops animating and reports successful, failed, and cancelled counts. A finished batch can contain failures. Retry commands start new attempts only for failed tasks on an active team. Previous attempts remain available in `/agents view` within its bounded session history; creating a new team replaces the current task list. + Creating a team from the default interactive editing mode also changes the session to **AUTO** so teammates can proceed without approval prompts. An explicit PLAN or YOLO choice is preserved. ACP/RPC sessions keep their client- diff --git a/install.sh b/install.sh index 414974373..0477f4d9f 100755 --- a/install.sh +++ b/install.sh @@ -199,6 +199,58 @@ install_computer_control() { fi } +install_windows() { + local _channel="$1" + shift + local _powershell="" _candidate _tmp_dir _script _url _status _clean=0 _no_cache=0 + for _candidate in powershell.exe pwsh.exe powershell pwsh; do + if command -v "$_candidate" >/dev/null 2>&1; then + _powershell="$_candidate" + break + fi + done + [ -n "$_powershell" ] || err "PowerShell is required for Windows installation. Open PowerShell and run the Windows installer: https://autohand.ai/install.ps1" + for _candidate in "$@"; do + case "$_candidate" in + --clean) _clean=1 ;; + --no-cache) _no_cache=1 ;; + esac + done + _tmp_dir=$(mktemp -d) || return 1 + _script="$_tmp_dir/install.ps1" + _url="https://github.com/${REPO}/releases/latest/download/install.ps1" + if [ -n "${AUTOHAND_VERSION:-}" ] && [ "$AUTOHAND_VERSION" != "latest" ]; then + _url="https://github.com/${REPO}/releases/download/v${AUTOHAND_VERSION#v}/install.ps1" + fi + if ! curl -fsSL "$_url" -o "$_script"; then + rm -rf "$_tmp_dir" + err "Could not download the Windows installer from $_url" + fi + if command -v cygpath >/dev/null 2>&1; then + _script=$(cygpath -w "$_script") || { rm -rf "$_tmp_dir"; return 1; } + fi + set -- -NoProfile -ExecutionPolicy Bypass -File "$_script" + [ "$_channel" != "alpha" ] || set -- "$@" -Alpha + [ "${AUTOHAND_INSTALL_FRESH:-0}" != "1" ] || set -- "$@" -Fresh + [ "${AUTOHAND_SKIP_ZIT_INSTALL:-0}" != "1" ] || set -- "$@" -NoZit + [ "$_clean" != "1" ] || set -- "$@" -Clean + [ "$_no_cache" != "1" ] || set -- "$@" -NoCache + if [ -n "${AUTOHAND_VERSION:-}" ] && [ "$AUTOHAND_VERSION" != "latest" ]; then + set -- "$@" -Version "${AUTOHAND_VERSION#v}" + fi + if [ -n "${AUTOHAND_INSTALL_DIR:-}" ]; then + local _install_dir="$AUTOHAND_INSTALL_DIR" + if command -v cygpath >/dev/null 2>&1; then + _install_dir=$(cygpath -w "$_install_dir") || { rm -rf "$_tmp_dir"; return 1; } + fi + set -- "$@" -InstallDir "$_install_dir" + fi + info "Using the native Windows installer (PowerShell)." + if "$_powershell" "$@"; then _status=0; else _status=$?; fi + rm -rf "$_tmp_dir" + return "$_status" +} + main() { printf "${BLUE}" cat << 'EOF' @@ -245,6 +297,10 @@ EOF get_architecture || return 1 local _arch="$RETVAL" + case "$_arch" in + windows-*) install_windows "$_channel" "$@"; return $? ;; + esac + local _version="${AUTOHAND_VERSION:-latest}" local _asset_name="autohand-${_arch}.tar.gz" local _url diff --git a/package.json b/package.json index 0d11c4608..ad7885364 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "main": "dist/index.js", "files": [ + "COMMERCIAL.md", "dist", "scripts/ensure-node-pty-helper-permissions.mjs", "scripts/ensure-ink-cursor-intent.mjs", @@ -77,7 +78,15 @@ "llm", "agent", "agentic", - "autohand" + "autohand", + "coding-agent", + "ai-coding-agent", + "claude-code", + "codex", + "mcp", + "ollama", + "byok", + "terminal" ], "engines": { "node": ">=22.0.0" diff --git a/scripts/install-computer-use.mjs b/scripts/install-computer-use.mjs index ad91de03a..5e8b45695 100644 --- a/scripts/install-computer-use.mjs +++ b/scripts/install-computer-use.mjs @@ -19,6 +19,13 @@ const COMPUTER_INSTALL_ARGS = [ ]; function runComputerUseCli({ entrypoint, packageRoot, environment }) { + const help = spawnSync(process.execPath, [entrypoint, '--help'], { + cwd: packageRoot, env: environment, encoding: 'utf8', windowsHide: true, + timeout: 30_000, + }); + if (help.status !== 0 || !/^\s+computer(?:\s|$)/m.test(help.stdout ?? '')) { + return { status: 'skipped-unsupported' }; + } const result = spawnSync(process.execPath, [entrypoint, ...COMPUTER_INSTALL_ARGS], { cwd: packageRoot, env: environment, @@ -56,8 +63,14 @@ export async function installComputerUsePostinstall(options = {}) { } const runCli = options.runCli ?? runComputerUseCli; - await runCli({ entrypoint, packageRoot, environment }); - return { status: 'completed' }; + try { + const result = await runCli({ entrypoint, packageRoot, environment }); + return result ?? { status: 'completed' }; + } catch (error) { + const warn = options.warn ?? console.warn; + warn(`Warning: Optional Computer control could not be installed. Autohand CLI is ready. ${error instanceof Error ? error.message : String(error)}`); + return { status: 'failed-optional' }; + } } function isMainModule() { @@ -67,6 +80,9 @@ function isMainModule() { if (isMainModule()) { installComputerUsePostinstall().then((result) => { + if (result.status === 'skipped-unsupported') { + console.log('Computer control arrives in the next release'); + } if (result.status === 'skipped-development') { console.log( 'Skipping Autohand Computer Use postinstall in a source checkout. ' diff --git a/src/actions/command.ts b/src/actions/command.ts index cea0279af..d9ee2277c 100644 --- a/src/actions/command.ts +++ b/src/actions/command.ts @@ -10,6 +10,7 @@ import { isAbsolute, join } from 'node:path'; import { buildAutohandChildProcessEnv } from '../utils/childProcessEnv.js'; import { prepareCommandCoordination } from '../session/peers/CommandCoordinationGate.js'; import { CommandOutputCapture } from '../utils/commandOutputCapture.js'; +import { resolveShellExecution } from '../utils/shellExecution.js'; const DEFAULT_KILL_GRACE_PERIOD_MS = 1_000; @@ -141,6 +142,8 @@ export async function runCommand( const workDir = options.directory ? (isAbsolute(options.directory) ? options.directory : join(cwd, options.directory)) : cwd; + const shellExecution = options.shell ? resolveShellExecution([cmd, ...args].join(' ')) : undefined; + const execution = shellExecution ?? { file: cmd, args }; const coordinated = await prepareCommandCoordination({ file: cmd, args, cwd: workDir }, options.signal); if (options.signal?.aborted) { const error = new CommandAbortedError(); @@ -155,7 +158,8 @@ export async function runCommand( // Build spawn options const spawnOptions: SpawnOptions = { cwd: workDir, - shell: options.shell ?? false, + shell: false, + windowsVerbatimArguments: shellExecution?.windowsVerbatimArguments, env: buildAutohandChildProcessEnv(options.env), ...(coordinated ? { detached: true } : {}), }; @@ -176,7 +180,7 @@ export async function runCommand( // uncaught exceptions from crashing the process. let child; try { - child = spawn(cmd, args, spawnOptions); + child = spawn(execution.file, execution.args, spawnOptions); coordinated?.observe(child); } catch (error) { const spawnError = toCommandSpawnError(error, cmd, workDir); diff --git a/src/actions/web.ts b/src/actions/web.ts index a747ce5c5..89213b1ac 100644 --- a/src/actions/web.ts +++ b/src/actions/web.ts @@ -9,7 +9,9 @@ import * as https from 'https'; import * as http from 'http'; -import { existsSync } from 'fs'; +import { existsSync, lstatSync, readdirSync } from 'fs'; +import { homedir } from 'node:os'; +import * as path from 'node:path'; import { spawn } from 'child_process'; import { getCommandCoordination, signalCoordinatedProcess, spawnCoordinatedProcess, waitForProcessPublication } from '../session/peers/CommandCoordinationGate.js'; import { hasBrowserBridgeOutput } from '../browser/browserToolBridge.js'; @@ -80,6 +82,7 @@ let globalSearchConfig: SearchConfig = { provider: 'browser-profile' }; let managedSearch: ManagedWebSearch | undefined; +let managedSearchAvailable = false; let searchConfigurationRevision = 0; /** @@ -94,6 +97,7 @@ export function configureSearchFromSettings( providerOverride?: SearchConfig['provider'], ): void { managedSearch = undefined; + managedSearchAvailable = false; searchConfigurationRevision += 1; configureSearch({ provider: providerOverride ?? settings.provider ?? 'browser-profile', @@ -112,11 +116,14 @@ export async function configureSearchFromAccount( const revision = searchConfigurationRevision; const identity = managedSearchIdentity(config); const client = identity ? new ManagedWebSearch(identity) : undefined; - const included = !providerOverride && !config.search?.provider && client ? await client.available(signal) : false; + const selected = providerOverride ?? config.search?.provider; + const checkIncluded = !selected || selected === 'browser-profile' || selected === 'autohand'; + const included = checkIncluded && client ? await client.available(signal) : false; if (revision !== searchConfigurationRevision) return; signal?.throwIfAborted(); - if (included) configureSearch({ provider: 'autohand' }); + if (included && !selected) configureSearch({ provider: 'autohand' }); managedSearch = client; + managedSearchAvailable = included; } /** @@ -148,9 +155,12 @@ export function isSearchConfigured(): boolean { switch (config.provider) { case 'autohand': - return !!managedSearch; - case 'browser-profile': - return hasBrowserBridgeOutput() || !!findChromePath(); + return managedSearchAvailable; + case 'browser-profile': { + if (hasBrowserBridgeOutput() || managedSearchAvailable) return true; + const profile = findBrowserProfile(); + return !!findChromePath() && !!profile && !isBrowserProfileLocked(profile.userDataDir); + } case 'exa': return !!exaKey; case 'brave': @@ -607,7 +617,7 @@ export async function webSearch(query: string, options: WebSearchOptions = {}): switch (provider) { case 'autohand': - if (!managedSearch) throw new Error('Sign in to Autohand for included web search (Lite and above), or configure your own provider with /search.'); + if (!managedSearch) throw new Error('Sign in to Autohand for included web search, or configure your own provider with /search.'); return managedSearch.search(enhancedQuery, maxResults, options.signal); case 'browser-profile': return browserProfileSearch( @@ -986,17 +996,23 @@ async function browserProfileSearch( } } + const profile = findBrowserProfile(); + throwIfAborted(signal); + if (profile && isBrowserProfileLocked(profile.userDataDir)) { + if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); + throw new Error("Chrome is open on your profile, so browser search can't run. Close Chrome, or pick another provider with /search."); + } const chromePath = findChromePath(); if (!chromePath) { + if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); throw new Error( 'No connected Chromium bridge or Chrome/Chromium installation was found. Connect Chromium or configure another provider with /search.' ); } // Find a user profile to use - const profile = await findBrowserProfile(); - throwIfAborted(signal); if (!profile) { + if (managedSearchAvailable && managedSearch) return managedSearch.search(query, maxResults, signal); throw new Error('Browser Profile search needs a browser profile. Connect Chromium or choose a provider with /search.'); } @@ -1140,13 +1156,8 @@ function parseBrowserSearchResults(payload: string, maxResults: number): WebSear * Detect user's browser profile to use for searching. * Returns the profile directory and user data dir for Chrome/Chromium/Brave/Edge. */ -async function findBrowserProfile(): Promise<{ userDataDir: string; profileDirectory: string; browser: string } | null> { - const os = await import('node:os'); - const path = await import('node:path'); - const fs = await import('fs-extra'); - const { pathExists } = fs; - - const homeDir = os.homedir(); +function findBrowserProfile(): { userDataDir: string; profileDirectory: string; browser: string } | null { + const homeDir = homedir(); const platform = process.platform; // Define browser data roots by platform @@ -1178,12 +1189,12 @@ async function findBrowserProfile(): Promise<{ userDataDir: string; profileDirec // Find the first browser with a valid profile for (const browser of browserRoots) { - if (!(await pathExists(browser.userDataDir))) { + if (!existsSync(browser.userDataDir)) { continue; } try { - const entries = await fs.readdir(browser.userDataDir); + const entries = readdirSync(browser.userDataDir); const profiles = entries.filter((entry: string) => entry === 'Default' || entry.startsWith('Profile ') ); @@ -1205,6 +1216,16 @@ async function findBrowserProfile(): Promise<{ userDataDir: string; profileDirec return null; } +function isBrowserProfileLocked(userDataDir: string): boolean { + try { + // Chromium's lock can be a dangling symlink; existsSync follows it and misses the lock. + lstatSync(path.join(userDataDir, 'SingletonLock')); + return true; + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ENOENT'); + } +} + /** * Fetch and extract content from a URL */ diff --git a/src/actions/worktree.ts b/src/actions/worktree.ts index b9882cdf6..9bc2aa1fb 100644 --- a/src/actions/worktree.ts +++ b/src/actions/worktree.ts @@ -231,18 +231,39 @@ export class WorktreeManager { /** * Remove a worktree with cleanup */ - async remove(worktreePath: string, options: { force?: boolean; deleteBranch?: boolean } = {}): Promise { + async remove(worktreePath: string, options: { + force?: boolean; + deleteBranch?: boolean; + baseRef?: string; + /** The caller must authorize discarding through the deletion permission policy. */ + discardChanges?: boolean; + } = {}): Promise { const absolutePath = path.isAbsolute(worktreePath) ? worktreePath : path.resolve(this.repoRoot, worktreePath); // Get branch info before removal const worktrees = this.list(); - const wt = worktrees.find(w => w.path === absolutePath); + const wt = worktrees.find(w => path.resolve(w.path) === path.resolve(absolutePath)); + if (!wt || wt.bare) throw new Error(`Not a registered worktree: ${absolutePath}`); const branchToDelete = wt?.branch; + const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: absolutePath, encoding: 'utf8', + }); + const commits = spawnSync('git', ['rev-list', '--count', `${options.baseRef ?? this.getMainBranch()}..HEAD`], { + cwd: absolutePath, encoding: 'utf8', + }); + if (status.status !== 0 || commits.status !== 0 || !/^\d+\s*$/.test(commits.stdout)) { + throw new Error(`Cannot verify worktree safety; kept ${absolutePath} (branch ${branchToDelete ?? '(detached)'}).`); + } + const hasChanges = status.stdout.trim().length > 0 || Number(commits.stdout.trim()) > 0; + if (hasChanges && options.discardChanges !== true) { + return `Kept worktree ${absolutePath} (branch ${branchToDelete ?? '(detached)'}) with changes (kept: has changes).`; + } + const args = ['worktree', 'remove']; - if (options.force) { + if (options.discardChanges === true) { args.push('--force'); } args.push(absolutePath); @@ -304,8 +325,8 @@ export class WorktreeManager { const removed: string[] = []; for (const wt of toRemove) { try { - await this.remove(wt.path, { force: true, deleteBranch: options.removeMerged }); - removed.push(wt.path); + const result = await this.remove(wt.path, { deleteBranch: options.removeMerged, baseRef: mainBranch }); + if (!result.startsWith('Kept worktree ')) removed.push(wt.path); } catch { // Skip if removal fails } diff --git a/src/cloud/CloudClient.ts b/src/cloud/CloudClient.ts index 731c5cf5f..0dfb5c83d 100644 --- a/src/cloud/CloudClient.ts +++ b/src/cloud/CloudClient.ts @@ -115,7 +115,7 @@ export class CloudClient { try { detail = z.object({ error: z.object({ message: z.string().max(1000), code: z.string().max(100).optional() }) }).parse(await readBoundedJson(response, 8192)); } catch { discardResponseBody(response); } const message = response.status === 401 ? 'Sign in again with autohand login to reconnect to Cloud.' - : response.status === 402 ? 'Cloud CLI sessions are available on Pro, Pro+, and Max. Upgrade in Console.' + : response.status === 402 ? 'Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.' : response.status === 403 || response.status === 404 ? 'This cloud session is unavailable in the selected account.' : detail?.error.message ?? 'Autohand Cloud could not complete this request. Reconnect to check the saved session before retrying.'; throw new CloudRequestError(message, response.status, detail?.error.code); diff --git a/src/cloud/launchCloud.tsx b/src/cloud/launchCloud.tsx index 82200257a..ff3366f67 100644 --- a/src/cloud/launchCloud.tsx +++ b/src/cloud/launchCloud.tsx @@ -119,7 +119,8 @@ export async function launchCloud(context: CloudLaunchContext, options: CLIOptio conversation = (await client.detail(selected)).conversation; } else { const plan = await client.call('account', z.object({ tier: z.string().nullable(), paymentAccess: z.unknown().nullish() })); - if (plan.paymentAccess || !['pro', 'pro_plus', 'max'].includes(plan.tier ?? '')) throw new Error('Cloud CLI sessions are available on Pro, Pro+, and Max. Upgrade in Console.'); + if (plan.paymentAccess) throw new Error("Cloud CLI sessions are paused while there's a billing issue on this account. Check billing in Console."); + if (!['pro', 'pro_plus', 'max', 'team', 'enterprise'].includes(plan.tier ?? '')) throw new Error('Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.'); prompt = prompt ?? await input(context.snapshot ? 'Task for the cloud agent after handoff' : 'Task for your cloud agent') ?? undefined; if (!prompt?.trim()) return { kind: 'cancelled' }; const models = (await client.call('models', modelsSchema)).models; diff --git a/src/commands/search.ts b/src/commands/search.ts index 183197eab..c9f8d624a 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -38,8 +38,18 @@ export async function search(ctx: SearchContext): Promise { // Provider selection const identity = config ? managedSearchIdentity(config) : undefined; - const included = identity ? await new ManagedWebSearch(identity).available() : false; - const providerOptions: ModalOption[] = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== 'autohand' || included).map(option => ({ ...option })); + const capability = identity ? await new ManagedWebSearch(identity).status() : null; + const included = capability?.available ?? false; + if (capability?.tier === 'free' && capability.usage) { + console.log(chalk.gray(`${capability.usage.remaining} of 10 free web searches left today. Resets at 00:00 UTC.`)); + console.log(); + } + const providerOptions: ModalOption[] = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== 'autohand' || included).map(option => ({ + ...option, + ...(option.value === 'autohand' && capability?.tier === 'free' && capability.usage ? { + description: `${capability.usage.remaining} of 10 free web searches left today. Resets at 00:00 UTC.`, + } : {}), + })); try { const result = await showModal({ diff --git a/src/commands/team.ts b/src/commands/team.ts index 911de6196..439833778 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -17,6 +17,10 @@ export const metadata: SlashCommand = { { name: 'create', description: 'Create a new team' }, { name: 'view', description: 'Open the live team activity view' }, { name: 'status', description: 'Show team status' }, + { name: 'errors', description: 'Inspect failed tasks and their output' }, + { name: 'retry', description: 'Retry one failed task by ID' }, + { name: 'retry-failed', description: 'Retry all failed tasks' }, + { name: 'hide', description: 'Hide the panel without stopping teammates' }, { name: 'shutdown', description: 'Shut down all teammates' }, ], }; @@ -40,6 +44,10 @@ export async function team(ctx: TeamCommandContext, args: string[]): Promise')} Create a new team`, ` ${chalk.cyan('/team view')} Open the live team activity view`, ` ${chalk.cyan('/team status')} Show current team status`, + ` ${chalk.cyan('/team errors')} Inspect failed tasks`, + ` ${chalk.cyan('/team retry ')} Retry a failed task`, + ` ${chalk.cyan('/team retry-failed')} Retry all failed tasks`, + ` ${chalk.cyan('/team hide')} Hide the panel; keep workers available`, ` ${chalk.cyan('/team shutdown')} Shut down all teammates`, ` ${chalk.cyan('/team')} Show this help`, '', @@ -53,7 +61,32 @@ export async function team(ctx: TeamCommandContext, args: string[]): Promise task.status === 'failed'); + if (!failures.length) return 'No failed team tasks.'; + return failures.map(task => [ + `${task.id} · ${task.subject}`, + task.error ?? 'No error details recorded.', + ...(task.output ? [task.output] : []), + ].join('\n')).join('\n\n'); + } + + case 'retry': + case 'retry-failed': { + if (ctx.teamManager.getTeam()?.status !== 'active') return 'This team is closed. Create a new team to start work.'; + const failures = ctx.teamManager.tasks.listTasks().filter(task => task.status === 'failed'); + const selected = subcommand === 'retry' ? failures.filter(task => task.id === args[1]) : failures; + if (subcommand === 'retry' && !selected.length) return 'Only failed tasks can be retried. Use /team errors to choose a task ID.'; + if (!selected.length) return 'No failed team tasks to retry.'; + for (const task of selected) ctx.teamManager.updateTask(task.id, { status: 'pending' }); + return `Retrying ${selected.length} failed task${selected.length === 1 ? '' : 's'}. Successful and cancelled tasks are unchanged. Previous attempts remain in /agents view.`; } case 'create': { @@ -121,6 +154,7 @@ export async function team(ctx: TeamCommandContext, args: string[]): Promise 0 || entries.mcpServers.length > 0) { + const tools = await collectProjectToolEntries(workspaceRoot); + const entries = resolveWorkspaceTrustEntries([projectConfig, workspaceSettings], tools); + if (entries.hooks.length > 0 || entries.mcpServers.length > 0 || entries.tools.length > 0) { const fingerprint = computeWorkspaceTrustFingerprint(entries); const trusted = await isWorkspaceTrusted(workspaceRoot, fingerprint, options.workspaceTrustStorePath); workspaceTrust = { workspaceRoot: path.resolve(workspaceRoot), fingerprint, trusted, ...entries }; @@ -625,6 +631,7 @@ export async function loadConfig( validateConfig(withEnv, configPath); configureChildProcessEnvPolicy(withEnv.shell?.env); + configureShellExecution(withEnv.shell); if (initializeTheme) { // Initialize theme from config. @@ -643,8 +650,14 @@ export async function loadConfig( }; } -/** Project hooks and MCP servers after layering, in the order they would apply. */ -function resolveWorkspaceTrustEntries(layers: (LocalProjectSettings | null)[]): WorkspaceTrustEntries { +/** + * Project hooks and MCP servers after layering, in the order they would apply, + * together with the project tool files the workspace ships. + */ +function resolveWorkspaceTrustEntries( + layers: (LocalProjectSettings | null)[], + tools: WorkspaceTrustToolEntry[] = [], +): WorkspaceTrustEntries { let hooks: HookDefinition[] = []; let mcpServers: McpServerConfigEntry[] = []; for (const layer of layers) { @@ -656,7 +669,7 @@ function resolveWorkspaceTrustEntries(layers: (LocalProjectSettings | null)[]): mcpServers = mergeMcpSettings({ servers: mcpServers }, layer.mcp).servers ?? []; } } - return { hooks, mcpServers }; + return { hooks, mcpServers, tools }; } function withoutExecutableSections(layer: LocalProjectSettings | null): LocalProjectSettings | null { @@ -785,9 +798,14 @@ function mergeWorkspaceSettings( // Merge permissions settings if (workspaceSettings.permissions) { + // PermissionManager keeps lists in their original scopes. Flattening these + // arrays or their import metadata could drop user denies or narrow them. + const { allowList, denyList, whitelist, blacklist, importedClaudeRules, ...projectOptions } = workspaceSettings.permissions; + void allowList; void denyList; void whitelist; void blacklist; void importedClaudeRules; merged.permissions = { ...merged.permissions, - ...workspaceSettings.permissions, + ...projectOptions, + workspaceScopes: [...(merged.permissions?.workspaceScopes ?? []), workspaceSettings.permissions], }; } @@ -1324,6 +1342,13 @@ function normalizeConfig( ); } + // Runtime permission scopes are never accepted from any saved config shape. + if (isPlainRecord(config.permissions)) { + const permissions = { ...config.permissions }; + delete permissions.workspaceScopes; + config = { ...config, permissions }; + } + if (isModernConfig(config)) { const provider = normalizeProviderName(config.provider) ?? "openrouter"; return { @@ -1402,6 +1427,16 @@ function isLegacyConfig( function validateShellSettings(config: AutohandConfig, configPath: string): void { if (config.shell === undefined) return; if (!isPlainObject(config.shell)) throw new Error(`shell must be an object in ${configPath}`); + const executable: unknown = config.shell.executable; + if (executable !== undefined && (typeof executable !== 'string' || !executable.trim() || /[\r\n\0]/u.test(executable))) { + throw new Error(`shell.executable must be a non-empty string in ${configPath}`); + } + if (config.shell.dialect !== undefined && (typeof config.shell.dialect !== 'string' || !['posix', 'cmd', 'powershell'].includes(config.shell.dialect))) { + throw new Error(`shell.dialect must be posix, cmd, or powershell in ${configPath}`); + } + if (config.shell.dialect !== undefined && executable === undefined) { + throw new Error(`shell.dialect requires shell.executable in ${configPath}`); + } const env: unknown = config.shell.env; if (env === undefined) return; if (!isPlainObject(env)) throw new Error(`shell.env must be an object in ${configPath}`); @@ -1670,6 +1705,9 @@ function validateConfig(config: AutohandConfig, configPath: string): void { throw new Error(`mcp.servers must be an array in ${configPath}`); } for (const server of config.mcp.servers) { + if (server.localOnly !== undefined && typeof server.localOnly !== "boolean") { + throw new Error(`mcp.servers[].localOnly must be boolean in ${configPath}`); + } if (!server.name || typeof server.name !== "string") { throw new Error( `mcp.servers[].name must be a non-empty string in ${configPath}`, @@ -2135,6 +2173,11 @@ export async function saveConfig( } } + if (data.permissions) { + data.permissions = { ...data.permissions }; + delete data.permissions.workspaceScopes; + } + await fs.ensureDir(path.dirname(configPath)); if (isYamlFile(configPath)) { diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index 311659b56..c68f70fc3 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -67,6 +67,7 @@ import { applyFormatter } from '../actions/formatters.js'; import { applyNotebookEdit } from '../actions/notebook.js'; import { loadCustomCommand, saveCustomCommand } from './customCommands.js'; import { webSearch, fetchUrl, getPackageInfo, formatSearchResults, formatPackageInfo } from '../actions/web.js'; +import { ManagedSearchCapError } from '../search/managedWebSearch.js'; import { webRepo, formatRepoInfo, formatRepoDir } from '../actions/webRepo.js'; import { projectTracker } from '../actions/projectTracker.js'; import { installSubAgentFromCatalog, searchSubAgentsCatalog } from '../actions/subAgentsCatalog.js'; @@ -339,6 +340,7 @@ const PEER_DIRECT_WRITE_ACTIONS = new Set([ export const MAX_SEARCH_CACHE_ENTRIES = 100; export class ActionExecutor { + private searchCapNoticeSession: string | undefined; private readonly runtime: AgentExecutorDeps['runtime']; private readonly files: AgentExecutorDeps['files']; private readonly resolveWorkspacePath: AgentExecutorDeps['resolveWorkspacePath']; @@ -403,7 +405,9 @@ export class ActionExecutor { this.projectManager = deps.projectManager; this.sessionId = deps.sessionId; this.logExploration = deps.onExploration; - this.toolsRegistry = deps.toolsRegistry ?? createToolsRegistry(deps.runtime.workspaceRoot); + this.toolsRegistry = deps.toolsRegistry ?? createToolsRegistry(deps.runtime.workspaceRoot, undefined, { + projectToolsTrusted: deps.runtime.config.workspaceTrust?.trusted === true, + }); this.metaToolService = deps.metaToolService ?? new MetaToolService(this.toolsRegistry); this.getRegisteredTools = deps.getRegisteredTools ?? (() => []); this.permissionManager = deps.permissionManager ?? new PermissionManager(deps.runtime.config.permissions); @@ -3067,12 +3071,23 @@ export class ActionExecutor { } console.log(chalk.cyan(`\n🔍 Searching web: "${action.query}"...`)); const { hasBrowserBridgeOutput, invokeBrowserTool } = await import('../browser/browserToolBridge.js'); - const results = await webSearch(action.query, { - maxResults: action.max_results, - searchType: action.search_type, - browserToolInvoker: hasBrowserBridgeOutput() ? invokeBrowserTool : undefined, - signal: context?.signal, - }); + let results: Awaited>; + try { + results = await webSearch(action.query, { + maxResults: action.max_results, + searchType: action.search_type, + browserToolInvoker: hasBrowserBridgeOutput() ? invokeBrowserTool : undefined, + signal: context?.signal, + }); + } catch (error) { + if (!(error instanceof ManagedSearchCapError)) throw error; + const session = this.deps.getCurrentSessionId?.() ?? this.sessionId ?? ''; + if (this.searchCapNoticeSession !== session) { + this.searchCapNoticeSession = session; + console.log(chalk.yellow(error.message)); + } + return error.message; + } const formatted = formatSearchResults(results); console.log(chalk.gray(formatted.split('\n').slice(0, 10).join('\n'))); if (results.length > 3) { diff --git a/src/core/agent.ts b/src/core/agent.ts index a08fb603a..f9dba34eb 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -2967,8 +2967,8 @@ export class AutohandAgent { return executeAgentSleepTool(this, seconds, reason); } - private async exitSessionWorktree(keep = false): Promise { - return exitAgentSessionWorktree(this, keep); + private async exitSessionWorktree(keep = false, discardChanges = false): Promise { + return exitAgentSessionWorktree(this, keep, discardChanges); } private isDestructiveCommand(command: string): boolean { diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index 11b7a5ede..17a0c1e10 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -357,8 +357,11 @@ export function parseAgentSlashCommand(_host: AgentCommandRuntimeHost, input: st } export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, message: string, context?: { tool?: string; path?: string; command?: string }): Promise { + const forcePrompt = context?.tool + ? host.permissionManager?.hasPlanPermissionPolicy?.({ ...context, tool: context.tool }) === true + : false; const normalizedYolo = normalizeYoloInput(host.runtime.options.yolo as string | boolean | undefined); - if (normalizedYolo && context?.tool) { + if (!forcePrompt && normalizedYolo && context?.tool) { try { const pattern = parseYoloPattern(normalizedYolo); if (isToolAllowedByYolo(context.tool, pattern)) { @@ -369,7 +372,7 @@ export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, } } - if (host.runtime.options.yes || host.runtime.options.unrestricted || host.runtime.config.ui?.autoConfirm) { + if (!forcePrompt && (host.runtime.options.yes || host.runtime.options.unrestricted || host.runtime.config.ui?.autoConfirm)) { return { decision: 'allow_once' }; } @@ -382,7 +385,7 @@ export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, if (host.confirmationCallback) { decision = normalizePermissionPromptResponse(await withProgramStatusWait('permission', message, () => host.confirmationCallback(message, context))); } else if (isExternalCallbackEnabled()) { - decision = normalizePermissionPromptResponse(await withProgramStatusWait('permission', message, () => unifiedConfirm(message, promptContext))); + decision = normalizePermissionPromptResponse(await withProgramStatusWait('permission', message, () => unifiedConfirm(message, promptContext, { forcePrompt }))); } else { host.notificationService.notify( { body: message, reason: 'confirmation' }, @@ -395,7 +398,7 @@ export async function confirmAgentDangerousAction(host: AgentCommandRuntimeHost, if (wasRaw) { safeSetRawMode(process.stdin as NodeJS.ReadStream, false); } - return unifiedConfirm(message, promptContext); + return unifiedConfirm(message, promptContext, { forcePrompt }); })); } @@ -643,6 +646,7 @@ export async function handleAgentExitPlanMode( if (selectedOption) { const accepted = await adoptPlanFileEdits(plan); const config = planManager.acceptPlan(selectedOption.id); + host.permissionManager?.setPlanEditApproval(config.autoAcceptEdits ? 'auto' : 'manual'); console.log(chalk.green(`\n✓ Plan accepted: ${selectedOption.label}`)); if (accepted.edited) { @@ -671,6 +675,7 @@ export async function handleAgentExitPlanMode( // Default: accept with manual approve if result wasn't recognized const accepted = await adoptPlanFileEdits(plan); planManager.acceptPlan('manual_approve'); + host.permissionManager?.setPlanEditApproval('manual'); console.log(chalk.green('\n✓ Plan accepted with manual approval for edits.\n')); host.conversation.addSystemNote( 'Plan accepted with option: manual_approve. You may now proceed to execution.' @@ -873,7 +878,7 @@ export async function executeAgentSleepTool(host: AgentCommandRuntimeHost, secon : `Slept for ${seconds} ${units}.`; } -export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, keep = false): Promise { +export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, keep = false, discardChanges = false): Promise { const state = host.sessionWorktreeState; if (!state) { return 'No active session worktree.'; @@ -881,10 +886,12 @@ export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, ke const workspaceChange: ThreadLease | undefined = host.sessionThreadBudget?.beginWorkspaceChange(); try { + let removal: string | undefined; if (!keep) { const manager = new WorktreeManager(state.repoRoot); - await manager.remove(state.worktreePath, { - force: true, + removal = await manager.remove(state.worktreePath, { + baseRef: state.baseRef, + discardChanges, deleteBranch: state.createdBranch, }); } @@ -894,7 +901,7 @@ export async function exitAgentSessionWorktree(host: AgentCommandRuntimeHost, ke return keep ? `Exited worktree ${state.worktreePath} and returned to ${state.originalWorkspaceRoot}. Worktree kept on disk.` - : `Exited worktree ${state.worktreePath} and returned to ${state.originalWorkspaceRoot}.`; + : `${removal}\nExited worktree ${state.worktreePath} and returned to ${state.originalWorkspaceRoot}.`; } finally { await workspaceChange?.release(); } diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index f0e590b51..b422c07f4 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -570,7 +570,9 @@ export function initializeAgentDependencies( const agentRegistry = configureAgentRegistry(runtime); const pluginDir = (runtime.config as typeof runtime.config & { pluginDir?: string }).pluginDir; - const toolsRegistry = createToolsRegistry(runtime.workspaceRoot, pluginDir ?? AUTOHAND_PATHS.tools); + const toolsRegistry = createToolsRegistry(runtime.workspaceRoot, pluginDir ?? AUTOHAND_PATHS.tools, { + projectToolsTrusted: runtime.config.workspaceTrust?.trusted === true, + }); host.toolsRegistry = toolsRegistry; host.extensionService = new ExtensionService({ projectRoot: join(runtime.workspaceRoot, PROJECT_DIR_NAME, 'extensions'), @@ -924,7 +926,13 @@ export function initializeAgentDependencies( const toolAuthorization = { permissionManager: host.permissionManager, - resolvePermissionContext: (action) => host.actionExecutor.getPermissionContext(action), + resolvePermissionContext: (action) => { + const context = host.actionExecutor.getPermissionContext(action); + return action.type === 'exit_worktree' && action.discard_changes === true && action.keep !== true + && host.sessionWorktreeState + ? { ...context, path: host.sessionWorktreeState.worktreePath } + : context; + }, runPreToolHooks: (context) => { const hookContext = { tool: context.tool, @@ -1680,7 +1688,7 @@ export function initializeAgentDependencies( } else if (action.type === 'enter_worktree') { result = await host.enterSessionWorktree(action.name); } else if (action.type === 'exit_worktree') { - result = await host.exitSessionWorktree(action.keep); + result = await host.exitSessionWorktree(action.keep, action.discard_changes); } else if (action.type === 'cron_create') { const cron = intervalToCron(action.interval); const expiresInMs = action.expires_in ? shorthandToMs(action.expires_in) : undefined; diff --git a/src/core/agent/InstructionRunner.ts b/src/core/agent/InstructionRunner.ts index a54b60539..09662b1f4 100644 --- a/src/core/agent/InstructionRunner.ts +++ b/src/core/agent/InstructionRunner.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { finalizeSessionGitSnapshot } from '../../actions/gitSessionSnapshot.js'; +import { getPlanModeManager } from '../../commands/plan.js'; import chalk from 'chalk'; import type { HookManager, HookExecutionResult } from '../HookManager.js'; import type { ComputerUseLifecycle } from '../../computer/ComputerUseLifecycle.js'; @@ -763,6 +764,8 @@ export class InstructionRunner { // are disabled. Otherwise, in-flight streaming output bypasses writeAbove // and writes directly to stdout while regions are still active, corrupting // the fixed-region composer box (overlapping borders, leaked tool data). + host.permissionManager?.setPlanEditApproval(undefined); + getPlanModeManager().completeExecution(); cleanupEsc(); stopPreparation(); host.stopStatusUpdates(); diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index 74038533c..8311d405c 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -46,6 +46,7 @@ import { import { VERTEX_AI_CODING_MODELS } from "../../providers/VertexAIProvider.js"; import { sanitizeModelId } from "../../providers/errors.js"; import { getOpenRouterModelContextWindow } from "../../providers/modelCapabilities.js"; +import { OPENROUTER_ATTRIBUTION_HEADERS } from "../../providers/OpenRouterClient.js"; import { saveConfig, getProviderConfig } from "../../config.js"; import { getContextWindow } from "../context/tokenizer.js"; import { @@ -3895,11 +3896,7 @@ export class ProviderConfigManager { ...(provider === "llmgateway" && { "x-source": "Autohand Code CLI", }), - ...(provider === "openrouter" && { - "HTTP-Referer": "https://autohand.dev", - "X-OpenRouter-Title": "Autohand Code CLI", - "X-OpenRouter-Categories": "cli-agent", - }), + ...(provider === "openrouter" && OPENROUTER_ATTRIBUTION_HEADERS), }, signal: AbortSignal.timeout(10000), // 10s timeout for validation }); diff --git a/src/core/agent/SystemPromptBuilder.ts b/src/core/agent/SystemPromptBuilder.ts index 61a8921ee..04582c8ec 100644 --- a/src/core/agent/SystemPromptBuilder.ts +++ b/src/core/agent/SystemPromptBuilder.ts @@ -15,6 +15,7 @@ import { isGoalFeatureEnabled } from '../../goals/feature.js'; import type { SkillSource } from '../../skills/types.js'; import type { AgentDefinition } from '../agents/AgentRegistry.js'; import { formatAgentRoster } from '../agents/agentRoster.js'; +import { getShellExecutionProfile } from '../../utils/shellExecution.js'; interface PromptSkillSummary { name: string; @@ -165,12 +166,15 @@ export class SystemPromptBuilder { ]); const authUser = runtime.config.auth?.user; + const shell = getShellExecutionProfile({ settings: runtime.config.shell }); const parts: string[] = [ 'You are Autohand, an expert AI software engineer built for the command line.', 'You are the best engineer in the world. You write code that is clean, efficient, maintainable, and easy to understand.', 'You are a master of your craft and can solve any problem with precision and elegance.', 'Your goal: Gather necessary information, clarify uncertainties, and decisively execute. Never stop until the task is fully complete.', + `Command shell: ${JSON.stringify(shell.executable)}`, + `Command dialect: ${shell.dialect}. Use this dialect for shell tools and command syntax.`, '', ...(authUser ? [ '## Current User', diff --git a/src/core/agent/TeamActivityBridge.ts b/src/core/agent/TeamActivityBridge.ts index fce46bf47..6a31346ce 100644 --- a/src/core/agent/TeamActivityBridge.ts +++ b/src/core/agent/TeamActivityBridge.ts @@ -65,7 +65,7 @@ export function attachTeamActivityBridge(options: TeamActivityBridgeOptions): () ]; options.notifyUser(completed === total ? `Team "${team.name}" completed ${total}/${total} tasks.` - : `Team "${team.name}" finished: ${outcomes.join(', ')}.`); + : `Team "${team.name}" finished: ${outcomes.join(', ')}. Inspect with /team view; stop workers with /team shutdown.`); } else if (!allTasksFinished) { completionAnnounced = false; } diff --git a/src/core/context/tokenizer.ts b/src/core/context/tokenizer.ts index 380edd728..e9d8084f4 100644 --- a/src/core/context/tokenizer.ts +++ b/src/core/context/tokenizer.ts @@ -66,6 +66,8 @@ function inferContextWindow(model: string): number | undefined { if (/^claude-sonnet-4[-.]5(?:$|[-.])/u.test(modelName)) return 1_000_000; if (/^claude-(?:haiku|opus)-4[-.]\d(?:$|[-.])/u.test(modelName)) return 200_000; + if (/^gpt-(?:5\.6|6(?:\.\d+)?)(?:$|-)/u.test(modelName)) return 272_000; + if (normalized.startsWith('gpt-5.5')) return 1_050_000; if (normalized.startsWith('gpt-5.4') && !normalized.includes('mini') && !normalized.includes('nano')) return 1_050_000; if ( diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 009ee6eb2..6535bbcb1 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -198,6 +198,9 @@ function resolveEffectivePermissionTool( action: AgentAction, values: Record, ): string { + if (action.type === 'exit_worktree' && values.discard_changes === true && values.keep !== true) { + return 'delete_path'; + } if (action.type === 'analyze_experiments' && values.operation === 'prune' && values.yes === true @@ -671,7 +674,7 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'run_command', - description: 'Execute a shell command in the user\'s shell with full pipe, redirect, and environment variable support. Cross-platform (bash/zsh on macOS/Linux, cmd/PowerShell on Windows). Prefer dedicated tools for file operations (read_file, write_file, find_grep, fff_find). For most commands, prefer the `shell` tool instead - it shows real-time output. Use this only for quick commands where you don\'t need progress monitoring.', + description: 'Execute a shell command using the configured command shell and dialect, with full pipe, redirect, and environment variable support. Supports POSIX shells, Windows cmd/PowerShell, and native Git Bash. Prefer dedicated tools for file operations (read_file, write_file, find_grep, fff_find). For most commands, prefer the `shell` tool instead - it shows real-time output. Use this only for quick commands where you don\'t need progress monitoring.', parameters: { type: 'object', properties: { @@ -1505,13 +1508,16 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'exit_worktree', - description: 'Exit the current session worktree and return to the original workspace. Optionally keep the worktree on disk for inspection.', + description: 'Return to the original workspace. Worktrees with edited or untracked files or commits not on the original branch are kept automatically. Discarding changes requires explicit deletion approval.', parameters: { type: 'object', properties: { - keep: { type: 'boolean', description: 'When true, keep the worktree and branch instead of removing them' } + keep: { type: 'boolean', description: 'When true, keep the worktree and branch instead of removing them' }, + discard_changes: { type: 'boolean', description: 'Explicitly discard worktree changes after deletion approval. Default false; keep takes precedence.' } } - } + }, + requiresApproval: true, + approvalMessage: 'Discard changes and remove the current session worktree? Uncommitted and untracked files will be deleted.' }, { name: 'team_status', @@ -1561,7 +1567,7 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'web_search', - description: 'Search the web for up-to-date information about packages, libraries, frameworks, documentation, changelogs, and more. Use this when you need current information that may have changed after your training data.', + description: 'Search the web for up-to-date information about packages, libraries, frameworks, documentation, changelogs, and more. Use this when you need current information that may have changed after your training data. If a daily limit is returned, relay the complete upgrade and reset sentence verbatim and finish using the evidence already available.', parameters: { type: 'object', properties: { @@ -2805,7 +2811,9 @@ export class ToolManager { continue; } - const requiresApproval = this.toolFilter.requiresApproval(call.tool, definition.requiresApproval); + const mayDiscard = call.tool !== 'exit_worktree' + || (this.getCallArgs(call).discard_changes === true && this.getCallArgs(call).keep !== true); + const requiresApproval = this.toolFilter.requiresApproval(call.tool, definition.requiresApproval && mayDiscard); try { this.assertNotAborted(signal); @@ -2984,6 +2992,7 @@ export class ToolManager { } shouldPrompt = permissionHookPromptOverride ?? shouldPrompt; } + if (this.permissionManager.requiresPlanApproval?.(permissionContext)) shouldPrompt = true; if (shouldPrompt) { const decision = normalizePermissionPromptResponse( await this.confirmApproval( @@ -3118,6 +3127,10 @@ export class ToolManager { } private resolvePermissionContexts(action: AgentAction): PermissionContext[] { + if (action.type === 'exit_worktree' && action.discard_changes === true && action.keep !== true + && !this.toolFilter.isAllowed('delete_path')) { + throw new Error('Discarding a worktree is not available in the current context.'); + } const specialized = this.resolveSpecializedPermissionContext?.(action); const standardContexts = buildToolPermissionContexts(action); const contexts = specialized === undefined diff --git a/src/core/toolsRegistry.ts b/src/core/toolsRegistry.ts index bf357db95..e0e450dce 100644 --- a/src/core/toolsRegistry.ts +++ b/src/core/toolsRegistry.ts @@ -63,14 +63,31 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function createToolsRegistry(workspaceRoot?: string, userToolsDir = AUTOHAND_PATHS.tools): ToolsRegistry { - const locations: ToolsRegistryLocation[] = workspaceRoot - ? [ - { scope: 'project', dir: path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools') }, - { scope: 'user', dir: userToolsDir }, - ] - : [{ scope: 'user', dir: userToolsDir }]; - return new ToolsRegistry(locations); +export interface ToolsRegistryOptions { + /** Project (.autohand/tools) definitions load only when the workspace is trusted. Defaults to false. */ + projectToolsTrusted?: boolean; +} + +const UNTRUSTED_PROJECT_TOOLS_REASON = 'project tools require a trusted workspace'; +const SHADOW_REASON_PREFIX = 'project tool shadows '; + +/** Active-cache precedence: a user tool always wins over a project tool of the same name. */ +const META_TOOL_PRECEDENCE: readonly MetaToolScope[] = ['user', 'project']; + +export function createToolsRegistry( + workspaceRoot?: string, + userToolsDir = AUTOHAND_PATHS.tools, + options: ToolsRegistryOptions = {}, +): ToolsRegistry { + const registry = new ToolsRegistry([{ scope: 'user', dir: userToolsDir }]); + if (workspaceRoot) { + const projectDir = path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools'); + registry.addLocation({ scope: 'project', dir: projectDir }); + if (!options.projectToolsTrusted) { + registry.noteUntrustedProjectTools(projectDir); + } + } + return registry; } export class ToolsRegistry { @@ -78,24 +95,43 @@ export class ToolsRegistry { private metaToolRecords: Map = new Map(); private diagnostics: MetaToolDiagnostic[] = []; private extensionToolRecords: Map = new Map(); + private reservedBuiltInNames: Set = new Set(); + private untrustedProjectToolsDir?: string; constructor(locations?: string | ToolsRegistryLocation[]) { this.locations = normalizeLocations(locations); } - private readonly locations: ToolsRegistryLocation[]; + private locations: ToolsRegistryLocation[]; + + addLocation(location: ToolsRegistryLocation): void { + this.locations.push(location); + } + + /** Remember a project tools directory that was skipped because the workspace is untrusted. */ + noteUntrustedProjectTools(dir: string): void { + this.untrustedProjectToolsDir = dir; + } + + /** Built-in tool names a project tool may never take; idempotent. */ + reserveBuiltInNames(names: Iterable): void { + for (const name of names) this.reservedBuiltInNames.add(name); + this.rebuildActiveCache(); + } async initialize(): Promise { this.metaToolCache.clear(); this.metaToolRecords.clear(); this.diagnostics = []; for (const location of this.locations) { - await fs.ensureDir(location.dir); + if (this.isLoadableLocation(location)) await fs.ensureDir(location.dir); } await this.loadMetaToolDefinitions(); } async listTools(builtIns: ToolDefinition[]): Promise { + // Keeps getMetaTool consistent with what the agent advertises as built-in. + this.reserveBuiltInNames(builtIns.map((tool) => tool.name)); const seen = new Set(); const entries: ToolRegistryEntry[] = []; @@ -331,7 +367,11 @@ export class ToolsRegistry { } private async loadMetaToolDefinitions(): Promise { + if (this.untrustedProjectToolsDir && (await fs.pathExists(this.untrustedProjectToolsDir))) { + this.diagnostics.push({ file: this.untrustedProjectToolsDir, reason: UNTRUSTED_PROJECT_TOOLS_REASON }); + } for (const location of this.locations) { + if (!this.isLoadableLocation(location)) continue; try { const exists = await fs.pathExists(location.dir); if (!exists) { @@ -377,6 +417,10 @@ export class ToolsRegistry { return location; } + private isLoadableLocation(location: ToolsRegistryLocation): boolean { + return location.scope !== 'project' || location.dir !== this.untrustedProjectToolsDir; + } + private async readDefinition(filePath: string): Promise { if (!await fs.pathExists(filePath)) { return null; @@ -451,14 +495,24 @@ export class ToolsRegistry { private rebuildActiveCache(): void { this.metaToolCache.clear(); - for (const location of this.locations) { + this.diagnostics = this.diagnostics.filter((d) => !d.reason.startsWith(SHADOW_REASON_PREFIX)); + for (const scope of META_TOOL_PRECEDENCE) { for (const record of this.metaToolRecords.values()) { - if (record.definition.scope !== location.scope || record.definition.disabled) { + if (record.definition.scope !== scope || record.definition.disabled) { continue; } - if (!this.metaToolCache.has(record.definition.name)) { - this.metaToolCache.set(record.definition.name, record.definition); + const name = record.definition.name; + if (scope === 'project' && this.reservedBuiltInNames.has(name)) { + this.diagnostics.push({ file: record.filePath, reason: `${SHADOW_REASON_PREFIX}built-in tool "${name}"` }); + continue; } + if (this.metaToolCache.has(name)) { + if (scope === 'project') { + this.diagnostics.push({ file: record.filePath, reason: `${SHADOW_REASON_PREFIX}user tool "${name}"` }); + } + continue; + } + this.metaToolCache.set(name, record.definition); } } for (const [name, record] of this.extensionToolRecords) { diff --git a/src/extensions/cli.ts b/src/extensions/cli.ts index 089636c5e..7b0587d7b 100644 --- a/src/extensions/cli.ts +++ b/src/extensions/cli.ts @@ -340,7 +340,9 @@ export async function extensionServiceFor(program: Command): Promise { const env = input.env ?? process.env; - const shellPath = env.SHELL ?? env.ComSpec; + const parentShellPath = env.SHELL ?? env.ComSpec; + let executionShell: ReturnType | undefined; + try { executionShell = getShellExecutionProfile({ environment: env }); } catch { /* Keep error reporting available when shell resolution fails. */ } + const shellName = executionShell ? path.basename(executionShell.executable.replaceAll('\\', '/')) : 'unresolved'; + const parentShell = parentShellPath ? path.basename(parentShellPath.replaceAll('\\', '/')) : undefined; const size = process.stdout.isTTY ? `${process.stdout.columns}x${process.stdout.rows}` : undefined; const facts: Record = { os: `${process.platform} ${os.release()} (${process.arch})`, runtime: process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.version}`, - shell: shellPath ? path.basename(shellPath.replaceAll('\\', '/')) : undefined, + shell: shellName, + shellDialect: executionShell?.dialect, + parentShell: parentShell !== shellName ? parentShell : undefined, terminal: [env.TERM_PROGRAM, env.TERM_PROGRAM_VERSION].filter(Boolean).join(' '), term: env.TERM, multiplexer: env.TMUX ? 'tmux' : env.STY ? 'screen' : undefined, diff --git a/src/import/ClaudePermissionImport.ts b/src/import/ClaudePermissionImport.ts new file mode 100644 index 000000000..4c236c7ff --- /dev/null +++ b/src/import/ClaudePermissionImport.ts @@ -0,0 +1,297 @@ +import fs from 'fs-extra'; +import { promises as nodeFs } from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import type { ImportedClaudeRule } from '../permissions/importedClaudeRules.js'; +import { isSimplePermissionCommand } from '../permissions/commandParts.js'; +import { AUTOHAND_PATHS } from '../constants.js'; +import { detectConfigPath } from '../config.js'; +import { atomicWriteFile, atomicWriteJson, withFileLock } from '../utils/atomicFile.js'; +import { isRecord, type HookImportOptions } from './HookImportService.js'; +import type { ImportCategoryResult, ImportError, PermissionImportMapping, PermissionImportReport } from './types.js'; + +interface SettingsSource { path: string; workspaceRoot?: string } +interface PermissionPlan { + files: SettingsSource[]; + mappings: PermissionImportMapping[]; + errors: ImportError[]; + legacyPaths: string[]; +} + +const unsafeRuleText = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/; + +function translateRule(rule: string, workspaceRoot?: string): string[] | null { + if (!rule || rule !== rule.trim() || unsafeRuleText.test(rule)) return null; + if (/^mcp__[\w.-]+__[\w.-]+$/.test(rule)) return [rule + ':*']; + const match = /^(Bash|Read|Edit|Write|WebFetch)(?:\((.*)\))?$/.exec(rule); + if (!match) return null; + const [, tool, argument] = match; + if (tool === 'Bash') { + if (argument !== undefined && (!argument || argument !== argument.trim() || /[?\[\]{}]/.test(argument))) return null; + const prefix = argument?.endsWith(':*') ? argument.slice(0, -2) : argument; + if (prefix !== undefined && !isSimplePermissionCommand(prefix)) return null; + const command = argument === undefined ? '*' : argument.endsWith(':*') ? argument.slice(0, -2) + '*' : argument; + return ['run_command:' + command, 'shell:' + command]; + } + if (tool === 'WebFetch') return argument === undefined ? ['fetch_url:*'] : null; + if (argument !== undefined && (!argument || /[?\[\]{}~\\]/.test(argument) || argument.startsWith('/') || !argument.startsWith('./') || (argument.includes('*') && (!argument.endsWith('/**') || argument.slice(0, -3).includes('*'))))) return null; + let target = argument ?? '*'; + if (path.isAbsolute(target)) { + if (!workspaceRoot) return null; + target = path.relative(workspaceRoot, target); + if (target.startsWith('..') || path.isAbsolute(target)) return null; + } + target = target.replace(/^\.\//, ''); + if (!target || target.split('/').includes('..')) return null; + const capability = tool === 'Read' ? 'read_file' : 'write_file'; + const patterns = [capability + ':' + target]; + if (workspaceRoot && argument !== undefined && !target.includes('*')) { + patterns.push(capability + ':' + path.resolve(workspaceRoot, target)); + } + return patterns; +} + +function report(plan: PermissionPlan, scrubbed = false): PermissionImportReport { + const translated = plan.mappings.filter(mapping => mapping.status === 'translated'); + return { + allow: translated.filter(mapping => mapping.disposition === 'allow').length, + deny: translated.filter(mapping => mapping.disposition === 'deny').length, + repositories: new Set(translated.flatMap(mapping => mapping.workspaceRoot ? [mapping.workspaceRoot] : [])).size, + skipped: plan.mappings.filter(mapping => mapping.status === 'skipped').length, + mappings: plan.mappings, + errors: plan.errors, + legacySettingsScrubbed: scrubbed, + legacySettingsToScrub: plan.legacyPaths, + }; +} + +const printable = (value: string) => value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, ' '); + +export function permissionImportLines(value: PermissionImportReport, dryRun = false): string[] { + const lines = [(dryRun ? 'Would translate ' : 'Translated ') + value.allow + ' allow and ' + value.deny + ' deny rules across ' + value.repositories + ' repos; ' + value.skipped + ' skipped']; + if (dryRun) lines.push('Scope | Rule | Translation'); + for (const error of value.errors ?? []) lines.push(printable(error.item + ': ' + error.error)); + for (const mapping of value.mappings) { + if (!dryRun && mapping.status === 'translated') continue; + const result = mapping.status === 'translated' ? mapping.patterns.join(', ') : mapping.status + ' (' + mapping.reason + ')'; + lines.push(printable((mapping.workspaceRoot ?? 'User') + ' | ' + mapping.disposition + ' ' + mapping.rule + ' | ' + result)); + } + if (dryRun) for (const file of value.legacySettingsToScrub) lines.push('Would scrub raw settings and environment data from ' + printable(file)); + else if (value.legacySettingsScrubbed) lines.push('Scrubbed raw Claude settings and environment data from the legacy import file.'); + return lines; +} + +export class ClaudePermissionImport { + constructor(private readonly sourceHome: string, private readonly options: HookImportOptions = {}) {} + + private async recordedWorkspace(file: string): Promise { + const handle = await nodeFs.open(file, 'r'); + try { + const buffer = Buffer.alloc(128 * 1024); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + for (const line of buffer.subarray(0, bytesRead).toString('utf8').split('\n')) { + try { + const value: unknown = JSON.parse(line); + if (isRecord(value) && typeof value.cwd === 'string' && path.isAbsolute(value.cwd)) return path.normalize(value.cwd); + } catch { /* Incomplete or non-JSON header lines do not identify a repository. */ } + } + return null; + } finally { await handle.close(); } + } + + private async discover(): Promise { + const roots = new Set([path.resolve(this.options.workspaceRoot ?? process.cwd())]); + const registry = this.sourceHome + '.json'; + if (await fs.pathExists(registry)) { + try { + const data: unknown = JSON.parse(await fs.readFile(registry, 'utf8')); + if (isRecord(data) && isRecord(data.projects)) for (const root of Object.keys(data.projects)) if (path.isAbsolute(root)) roots.add(path.normalize(root)); + } catch { /* Session headers and the selected workspace remain independent sources. */ } + } + const projects = path.join(this.sourceHome, 'projects'); + if (await fs.pathExists(projects)) { + for (const directory of await fs.readdir(projects, { withFileTypes: true })) { + if (!directory.isDirectory()) continue; + const folder = path.join(projects, directory.name); + for (const entry of await fs.readdir(folder, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + try { + const root = await this.recordedWorkspace(path.join(folder, entry.name)); + if (root) { roots.add(root); break; } + } catch { /* An unreadable session does not justify decoding its lossy directory name. */ } + } + } + } + const files: SettingsSource[] = []; + const global = path.join(this.sourceHome, 'settings.json'); + if (await fs.pathExists(global)) files.push({ path: global }); + for (const root of roots) { + for (const name of ['settings.json', 'settings.local.json']) { + const file = path.join(root, '.claude', name); + if (await fs.pathExists(file)) files.push({ path: file, workspaceRoot: root }); + } + } + return files; + } + + async scan(): Promise { + const files = await this.discover(); + return files.length || (await fs.pathExists(path.join(AUTOHAND_PATHS.config, 'imported-claude-settings.json')) ? 1 : 0); + } + + private async needsScrub(file: string): Promise { + if (!await fs.pathExists(file)) return false; + try { + const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); + return !isRecord(value) || Object.hasOwn(value, 'raw') || Object.hasOwn(value, 'env'); + } catch { return true; } + } + + private async plan(): Promise { + const configPath = await detectConfigPath(this.options.configPath); + const files = await this.discover(); + const plan: PermissionPlan = { files, mappings: [], errors: [], legacyPaths: [] }; + for (const file of files) { + try { + const data: unknown = JSON.parse(await fs.readFile(file.path, 'utf8')); + if (!isRecord(data) || (data.permissions !== undefined && !isRecord(data.permissions))) throw new Error('Invalid permissions'); + const permissions = isRecord(data.permissions) ? data.permissions : {}; + const destination = file.workspaceRoot ? path.join(file.workspaceRoot, '.autohand', 'settings.local.json') : configPath; + for (const disposition of ['allow', 'deny'] as const) { + const values = permissions[disposition]; + if (values === undefined) continue; + const entries: unknown[] = Array.isArray(values) ? values : [null]; + const seen = new Set(); + for (const entry of entries) { + const rule = typeof entry === 'string' ? entry : '[non-text rule]'; + if (seen.has(rule)) continue; + seen.add(rule); + const patterns = typeof entry === 'string' ? translateRule(entry, file.workspaceRoot) : null; + const mapping = { source: file.path, destination, ...(file.workspaceRoot ? { workspaceRoot: file.workspaceRoot } : {}), disposition, rule }; + plan.mappings.push(patterns ? { ...mapping, status: 'translated', patterns } : { ...mapping, status: 'skipped', reason: 'no equivalent' }); + } + } + } catch { + plan.errors.push({ category: 'settings', item: file.path, error: 'Could not read Claude permission settings. No raw settings were copied.', retriable: false }); + } + } + for (const home of new Set([AUTOHAND_PATHS.config, path.dirname(configPath)])) { + const file = path.join(home, 'imported-claude-settings.json'); + if (await this.needsScrub(file)) plan.legacyPaths.push(file); + } + return plan; + } + + async preview(): Promise { return report(await this.plan()); } + + private async updateDestination(file: string, mappings: readonly PermissionImportMapping[]): Promise { + const project = mappings.find(mapping => mapping.workspaceRoot)?.workspaceRoot; + const assertContained = async () => { + if (!project) return; + const root = await fs.realpath(project); + const directory = path.dirname(file); + if (await fs.pathExists(directory)) { + const actual = await fs.realpath(directory); + if (actual !== path.join(root, '.autohand')) throw new Error('Project settings directory escapes its repository'); + } + if (await fs.pathExists(file) && (await fs.lstat(file)).isSymbolicLink()) throw new Error('Project settings file is a symlink'); + }; + await assertContained(); + const merge = async () => { + await assertContained(); + const extension = path.extname(file); + const raw = await fs.pathExists(file) ? await fs.readFile(file, 'utf8') : extension === '.toml' ? '' : '{}'; + const data: unknown = extension === '.toml' ? parseToml(raw) : /\.ya?ml$/.test(extension) ? YAML.parse(raw) : JSON.parse(raw); + if (!isRecord(data) || (data.permissions !== undefined && !isRecord(data.permissions))) throw new Error('Invalid destination settings'); + const permissions = isRecord(data.permissions) ? data.permissions : {}; + const list = (canonical: string, legacy: string) => { + const value = permissions[canonical] ?? permissions[legacy] ?? []; + if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) throw new Error('Invalid destination permission list'); + return value as string[]; + }; + const currentAllow = list('allowList', 'whitelist'), currentDeny = list('denyList', 'blacklist'); + const patterns = (disposition: 'allow' | 'deny') => mappings.flatMap(mapping => mapping.status === 'translated' && mapping.disposition === disposition ? [...mapping.patterns] : []); + const allowList = [...new Set([...currentAllow, ...patterns('allow')])]; + const denyList = [...new Set([...currentDeny, ...patterns('deny')])]; + const existingMetadata = permissions.importedClaudeRules ?? []; + if (!Array.isArray(existingMetadata)) throw new Error('Invalid imported permission metadata'); + const importedClaudeRules = [...existingMetadata]; + for (const mapping of mappings) { + if (mapping.status !== 'translated') continue; + const existing = mapping.disposition === 'allow' ? currentAllow : currentDeny; + for (const pattern of mapping.patterns) { + // A pre-existing Autohand rule retains its original matching semantics. + if (existing.includes(pattern)) continue; + const kind: ImportedClaudeRule['kind'] = mapping.rule.startsWith('Bash(') + ? mapping.rule.endsWith(':*)') ? 'command-prefix' : 'command-exact' + : /^(Read|Edit|Write)\(/.test(mapping.rule) ? 'file' : 'tool'; + const metadata: ImportedClaudeRule = { pattern, action: mapping.disposition, kind }; + if (!importedClaudeRules.some(value => isRecord(value) && value.pattern === pattern && value.action === metadata.action)) importedClaudeRules.push(metadata); + } + } + if (JSON.stringify(currentAllow) === JSON.stringify(allowList) && JSON.stringify(currentDeny) === JSON.stringify(denyList) + && JSON.stringify(existingMetadata) === JSON.stringify(importedClaudeRules)) return null; + data.permissions = { ...permissions, allowList, denyList, importedClaudeRules }; + if (mappings.some(mapping => mapping.workspaceRoot) && data.version === undefined) data.version = 1; + return extension === '.toml' ? stringifyToml(data) : /\.ya?ml$/.test(extension) ? YAML.stringify(data) : JSON.stringify(data, null, 2) + '\n'; + }; + if (await merge() === null) return; + await fs.ensureDir(path.dirname(file)); + await withFileLock(file + '.lock', async () => { + const content = await merge(); + if (content !== null) await atomicWriteFile(file, content); + }); + } + + async scrubLegacy(files?: readonly string[]): Promise<{ scrubbed: boolean; errors: ImportError[] }> { + if (!files) files = (await this.plan()).legacyPaths; + const errors: ImportError[] = []; + let scrubbed = false; + for (const file of files) { + try { + await withFileLock(file + '.lock', async () => { + if (!await this.needsScrub(file)) return; + await atomicWriteJson(file, { importedFrom: 'claude', scrubbed: true }); + await fs.chmod(file, 0o600); + scrubbed = true; + }); + } catch { + errors.push({ category: 'settings', item: file, error: 'Could not scrub the legacy Claude settings file.', retriable: true }); + } + } + return { scrubbed, errors }; + } + + async cleanup(): Promise { + const plan = await this.plan(); + const cleanup = await this.scrubLegacy(plan.legacyPaths); + return report({ ...plan, files: [], mappings: [], errors: cleanup.errors }, cleanup.scrubbed); + } + + async import(): Promise<{ stats: ImportCategoryResult; errors: ImportError[]; report: PermissionImportReport }> { + const plan = await this.plan(); + const destinations = new Set(plan.mappings.filter(mapping => mapping.status === 'translated').map(mapping => mapping.destination)); + for (const destination of destinations) { + try { + await this.updateDestination(destination, plan.mappings.filter(mapping => mapping.destination === destination)); + } catch { + plan.errors.push({ category: 'settings', item: destination, error: 'Could not merge permission rules; existing settings were preserved.', retriable: false }); + plan.mappings = plan.mappings.map(mapping => mapping.destination === destination && mapping.status === 'translated' + ? { source: mapping.source, destination: mapping.destination, workspaceRoot: mapping.workspaceRoot, disposition: mapping.disposition, rule: mapping.rule, status: 'failed', reason: 'destination settings could not be updated' } + : mapping); + } + } + const cleanup = await this.scrubLegacy(plan.legacyPaths); + plan.errors.push(...cleanup.errors); + const scrubbed = cleanup.scrubbed; + const result = report(plan, scrubbed); + const failedSources = new Set(plan.errors.map(error => error.item)); + for (const mapping of plan.mappings) if (mapping.status === 'failed') failedSources.add(mapping.source); + const success = plan.files.filter(file => !failedSources.has(file.path)).length; + return { report: result, errors: plan.errors, stats: { + success, failed: plan.errors.length, skipped: result.skipped + (!plan.files.length && !plan.legacyPaths.length ? 1 : 0), + ...(result.skipped ? { skipReasons: { 'no equivalent': result.skipped } } : {}), + } }; + } +} diff --git a/src/import/importers/ClaudeImporter.ts b/src/import/importers/ClaudeImporter.ts index 2febb08a8..0ed3bbcfe 100644 --- a/src/import/importers/ClaudeImporter.ts +++ b/src/import/importers/ClaudeImporter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import fse from 'fs-extra'; import type { ImportSource, @@ -13,10 +14,16 @@ import type { ImportError, ImportCategoryResult, ProgressCallback, + PermissionImportReport, } from '../types.js'; import type { SessionMessage } from '../../session/types.js'; import { AUTOHAND_PATHS } from '../../constants.js'; +import { detectConfigPath } from '../../config.js'; +import YAML from 'yaml'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { BaseImporter } from './BaseImporter.js'; +import { ClaudePermissionImport } from '../ClaudePermissionImport.js'; +import { atomicWriteFile } from '../../utils/atomicFile.js'; /** * A single parsed Claude event from a session JSONL file. @@ -36,6 +43,32 @@ interface ClaudeEvent { [k: string]: unknown; } +type CollectedMcpServers = { + user: Record; + project: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validMcpServers(value: unknown): Record { + if (!isRecord(value)) return {}; + + return Object.fromEntries(Object.entries(value).filter(([name, server]) => { + if (!name.trim()) return false; + if (!isRecord(server)) return false; + if (server.type === 'http' || server.type === 'sse') return typeof server.url === 'string' && server.url.length > 0; + return (server.type === undefined || server.type === 'stdio') && typeof server.command === 'string' && server.command.length > 0; + })); +} + +function hasEnvPlaceholders(value: unknown): boolean { + if (typeof value === 'string') return /\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}/.test(value); + if (Array.isArray(value)) return value.some(hasEnvPlaceholders); + return isRecord(value) && Object.values(value).some(hasEnvPlaceholders); +} + /** * Importer for Claude Code CLI data (~/.claude). * @@ -46,6 +79,21 @@ export class ClaudeImporter extends BaseImporter { readonly name: ImportSource = 'claude'; readonly displayName = 'Claude Code'; readonly homePath = '~/.claude'; + private permissionReport?: PermissionImportReport; + + private permissionImport(): ClaudePermissionImport { + return new ClaudePermissionImport(this.resolvedHomePath, this.hookImportOptions); + } + + override async detect(): Promise { + return await super.detect() || await this.permissionImport().scan() > 0 + || await fse.pathExists(path.join(path.dirname(this.resolvedHomePath), '.claude.json')) + || await fse.pathExists(path.join(path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()), '.mcp.json')); + } + + async previewSettings(): Promise { + return this.permissionImport().preview(); + } // --------------------------------------------------------------- // scan() @@ -54,7 +102,13 @@ export class ClaudeImporter extends BaseImporter { async scan(): Promise { const available = new Map(); await this.scanHooks(available); + const settingsCount = await this.permissionImport().scan(); + if (settingsCount > 0) available.set('settings', { count: settingsCount, description: 'Claude user and project permission settings' }); const home = this.resolvedHomePath; + const mcp = await this.collectMcpServers(); + if (Object.keys(mcp.user).length + Object.keys(mcp.project).length > 0) { + available.set('mcp', { count: Object.keys(mcp.user).length + Object.keys(mcp.project).length, description: 'Claude MCP servers' }); + } if (!(await fse.pathExists(home))) { return { source: this.name, available }; @@ -93,12 +147,6 @@ export class ClaudeImporter extends BaseImporter { }); } - // Check settings - const settingsPath = path.join(home, 'settings.json'); - if (await fse.pathExists(settingsPath)) { - available.set('settings', { count: 1, description: 'Claude Code settings.json' }); - } - // Check skills const skillsDir = path.join(home, 'skills'); if (await fse.pathExists(skillsDir)) { @@ -134,6 +182,7 @@ export class ClaudeImporter extends BaseImporter { const start = Date.now(); const imported = new Map(); const errors: ImportError[] = []; + this.permissionReport = undefined; for (const category of categories) { switch (category) { @@ -152,20 +201,185 @@ export class ClaudeImporter extends BaseImporter { case 'memory': await this.importMemory(imported, errors, onProgress); break; + case 'mcp': + await this.importMcpServers(imported, errors); + break; default: - // MCP import is not supported here. break; } } + if (!categories.includes('settings')) { + this.permissionReport = await this.permissionImport().cleanup(); + errors.push(...(this.permissionReport.errors ?? [])); + } + return { source: this.name, imported, errors, duration: Date.now() - start, + ...(this.permissionReport ? { permissions: this.permissionReport } : {}), }; } + private async readOptionalJson(filePath: string): Promise | undefined> { + try { + const value: unknown = await fse.readJson(filePath); + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } + } + + // Collect user and current-workspace MCP servers + private async collectMcpServers(): Promise { + const claudeConfigPath = path.join(path.dirname(this.resolvedHomePath), '.claude.json'); + const workspaceRoot = path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()); + const projectConfigPath = path.join(workspaceRoot, '.mcp.json'); + const collected: CollectedMcpServers = { user: {}, project: {} }; + + const claudeConfig = await this.readOptionalJson(claudeConfigPath); + if (claudeConfig && isRecord(claudeConfig.mcpServers)) { + Object.assign(collected.user, validMcpServers(claudeConfig.mcpServers)); + } + + const localConfig = await this.readOptionalJson(projectConfigPath); + if (localConfig && isRecord(localConfig.mcpServers)) { + Object.assign(collected.project, validMcpServers(localConfig.mcpServers)); + } + + const projects = claudeConfig?.projects; + if (isRecord(projects)) { + const projectConfig = projects[workspaceRoot]; + if (isRecord(projectConfig) && isRecord(projectConfig.mcpServers)) { + Object.assign(collected.project, validMcpServers(projectConfig.mcpServers)); + } + } + + return collected; + } + + // Import user and workspace MCP servers, preserving existing entries and skipping duplicates. + private async importMcpServers(imported: Map, errors: ImportError[]): Promise { + const { user, project } = await this.collectMcpServers(); + const workspace = path.resolve(this.hookImportOptions.workspaceRoot ?? process.cwd()); + const destinations = [ + { servers: user, file: await detectConfigPath(this.hookImportOptions.configPath) }, + { servers: project, file: path.join(workspace, '.autohand', 'settings.local.json') }, + ]; + let success = 0; + let skipped = 0; + const skipReasons: Record = {}; + for (const { servers, file } of destinations) { + try { + if (!Object.keys(servers).length) continue; + if (servers === project) await this.assertProjectMcpDestination(workspace, file); + const current = await this.readConfigFile(file); + const mcp = isRecord(current.mcp) ? current.mcp : {}; + const existing = Array.isArray(mcp.servers) ? mcp.servers : []; + const converted = Object.entries(servers).flatMap(([name, value]) => { + if (!isRecord(value)) return []; + if (hasEnvPlaceholders(value)) { + errors.push({ category: 'mcp', item: name, error: 'Claude MCP environment variable placeholders are not supported by this importer', retriable: false }); + return []; + } + const server = value; + const transport = server.type === 'http' || server.type === 'sse' ? server.type : 'stdio'; + return [{ ...server, name, transport, autoConnect: false, localOnly: true }]; + }); + const existingNames = new Set(); + for (const server of existing) { + if (isRecord(server) && typeof server.name === 'string') { + existingNames.add(server.name); + } + } + const additions = converted.filter(server => !existingNames.has(server.name)); + const duplicateCount = converted.length - additions.length; + skipped += duplicateCount; + if (duplicateCount > 0) skipReasons['already exists'] = (skipReasons['already exists'] ?? 0) + duplicateCount; + + if (additions.length === 0) continue; + + const merged = [...existing, ...additions]; + if (servers === project) await this.ignoreLocalSettings(workspace); + await fse.ensureDir(path.dirname(file)); + if (servers === project) await this.assertProjectMcpDestination(workspace, file); + const output = { ...current, mcp: { ...mcp, servers: merged } }; + await this.writeConfigFile(file, output); + success += additions.length; + } catch (error) { errors.push({ category: 'mcp', item: file, error: error instanceof Error ? error.message : String(error), retriable: false }); } + } + imported.set('mcp', { + success, + failed: errors.filter(e => e.category === 'mcp').length, + skipped, + ...(skipped > 0 ? { skipReasons } : {}), + }); + } + + private async assertProjectMcpDestination(workspace: string, file: string): Promise { + const root = await fse.realpath(workspace); + const directory = path.dirname(file); + if (await fse.pathExists(directory) && await fse.realpath(directory) !== path.join(root, '.autohand')) { + throw new Error('Project MCP settings directory escapes its repository'); + } + try { + if ((await fse.lstat(file)).isSymbolicLink()) throw new Error('Project MCP settings file is a symlink'); + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') throw error; + } + } + + // Keep imported credentials out of Git without changing the shared .gitignore. + private async ignoreLocalSettings(workspace: string): Promise { + let excludeFile: string; + try { + excludeFile = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], { + cwd: workspace, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + const stderr = (error as NodeJS.ErrnoException & { stderr?: Buffer | string }).stderr?.toString() ?? ''; + if (stderr.includes('not a git repository')) return; + throw error; + } + + const pattern = '**/.autohand/settings.local.json'; + const current = await fse.pathExists(excludeFile) ? await fse.readFile(excludeFile, 'utf8') : ''; + if (current.split(/\r?\n/).includes(pattern)) return; + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + await fse.writeFile(excludeFile, `${current}${separator}${pattern}\n`); + } + + private async readConfigFile(file: string): Promise> { + if (!(await fse.pathExists(file))) return {}; + + const extension = path.extname(file).toLowerCase(); + let parsed: unknown; + if (extension === '.toml') { + parsed = parseToml(await fse.readFile(file, 'utf8')); + } else if (/\.ya?ml$/.test(extension)) { + parsed = YAML.parse(await fse.readFile(file, 'utf8')); + } else { + parsed = await fse.readJson(file); + } + + if (!isRecord(parsed)) { + throw new Error(`Expected a configuration object in ${file}`); + } + return parsed; + } + + private async writeConfigFile(file: string, config: Record): Promise { + const extension = path.extname(file).toLowerCase(); + const content = extension === '.toml' ? stringifyToml(config) + : /\.ya?ml$/.test(extension) ? YAML.stringify(config) : JSON.stringify(config, null, 2) + '\n'; + await atomicWriteFile(file, content); + await fse.chmod(file, 0o600); + } + // --------------------------------------------------------------- // Sessions // --------------------------------------------------------------- @@ -323,54 +537,33 @@ export class ClaudeImporter extends BaseImporter { errors: ImportError[], onProgress?: ProgressCallback, ): Promise { - const settingsPath = path.join(this.resolvedHomePath, 'settings.json'); - - if (!(await fse.pathExists(settingsPath))) { - imported.set('settings', { success: 0, failed: 0, skipped: 1 }); - return; - } - onProgress?.({ category: 'settings', current: 1, total: 1, - item: 'settings.json', + item: 'Claude permission rules', status: 'importing', }); try { - const settings = await this.safeReadJson(settingsPath); - - // Extract permissions.allow and merge into autohand config - const permissions = settings.permissions as { allow?: string[] } | undefined; - if (permissions?.allow) { - const configDir = AUTOHAND_PATHS.config; - await fse.ensureDir(configDir); - - const importedSettingsPath = path.join(configDir, 'imported-claude-settings.json'); - await fse.writeJson(importedSettingsPath, { - importedFrom: 'claude', - importedAt: new Date().toISOString(), - permissions: permissions.allow, - raw: settings, - }, { spaces: 2 }); - } - - imported.set('settings', { success: 1, failed: 0, skipped: 0 }); + const result = await this.permissionImport().import(); + this.permissionReport = result.report; + imported.set('settings', result.stats); + errors.push(...result.errors); onProgress?.({ category: 'settings', current: 1, total: 1, - item: 'settings.json', - status: 'done', + item: 'Claude permission rules', + status: result.stats.failed ? 'failed' : 'done', }); - } catch (err) { + } catch { imported.set('settings', { success: 0, failed: 1, skipped: 0 }); errors.push({ category: 'settings', item: 'settings.json', - error: err instanceof Error ? err.message : String(err), + error: 'Could not import Claude permission settings. No raw settings were copied.', retriable: false, }); } diff --git a/src/import/index.ts b/src/import/index.ts index 7111a63e3..b7ceccadf 100644 --- a/src/import/index.ts +++ b/src/import/index.ts @@ -7,7 +7,8 @@ * interactive (TTY) and non-interactive (CI/pipe) modes. */ import chalk from 'chalk'; -import type { ImportOptions, ImportCategory, ImportResult } from './types.js'; +import type { ImportOptions, ImportCategory, ImportResult, Importer } from './types.js'; +import { permissionImportLines } from './ClaudePermissionImport.js'; import { IMPORT_SOURCES } from './types.js'; /** @@ -55,6 +56,7 @@ export async function runImport(options: ImportOptions): Promise { if (options.dryRun) { printDryRun(scanResult.available, cats); + await printPermissionPreview(importer, cats); continue; } @@ -112,6 +114,7 @@ export async function runImport(options: ImportOptions): Promise { if (options.dryRun) { console.log(chalk.cyan(`Dry run — ${importer.displayName}:`)); printDryRun(scanResult.available, categories); + await printPermissionPreview(importer, categories); return; } @@ -140,9 +143,15 @@ function printDryRun( } } +export async function printPermissionPreview(importer: Importer, categories: ImportCategory[]): Promise { + if (!categories.includes('settings') || !importer.previewSettings) return; + for (const line of permissionImportLines(await importer.previewSettings(), true)) console.log(line); +} + function printSummary(result: ImportResult): void { console.log(); console.log(chalk.bold(`Import complete from ${result.source}:`)); + if (result.permissions) for (const line of permissionImportLines(result.permissions)) console.log(line); for (const [cat, stats] of result.imported) { const icon = stats.failed > 0 ? chalk.yellow('⚠') : chalk.green('✓'); diff --git a/src/import/types.ts b/src/import/types.ts index b9f366e63..9118f4707 100644 --- a/src/import/types.ts +++ b/src/import/types.ts @@ -64,6 +64,29 @@ export interface ImportResult { readonly imported: Map; readonly errors: ImportError[]; readonly duration: number; + readonly permissions?: PermissionImportReport; +} + +export type PermissionImportMapping = { + readonly source: string; + readonly destination: string; + readonly workspaceRoot?: string; + readonly disposition: 'allow' | 'deny'; + readonly rule: string; +} & ( + | { readonly status: 'translated'; readonly patterns: readonly string[] } + | { readonly status: 'skipped' | 'failed'; readonly reason: string } +); + +export interface PermissionImportReport { + readonly allow: number; + readonly deny: number; + readonly repositories: number; + readonly skipped: number; + readonly mappings: readonly PermissionImportMapping[]; + readonly errors?: readonly ImportError[]; + readonly legacySettingsScrubbed: boolean; + readonly legacySettingsToScrub: readonly string[]; } /** @@ -119,6 +142,8 @@ export interface Importer { /** Import selected categories, reporting progress via callback. */ import(categories: ImportCategory[], onProgress?: ProgressCallback): Promise; + /** Read-only permission preview; never creates configuration or lock files. */ + previewSettings?(): Promise; } /** diff --git a/src/import/ui/ImportWizard.tsx b/src/import/ui/ImportWizard.tsx index d699eb64f..73d87887e 100644 --- a/src/import/ui/ImportWizard.tsx +++ b/src/import/ui/ImportWizard.tsx @@ -13,6 +13,7 @@ import { showModal } from '../../ui/ink/components/Modal.js'; import { showCategorySelector, CATEGORY_LABELS } from './CategorySelector.js'; import { ImportProgressView } from './ImportProgress.js'; import type { ImporterRegistry } from '../registry.js'; +import { permissionImportLines } from '../ClaudePermissionImport.js'; import type { ImportCategory, ImportError, @@ -69,6 +70,8 @@ export function ImportSummary({ source, result }: ImportSummaryProps) { {'\u2713'} Import complete from {source} {''} + {result.permissions && permissionImportLines(result.permissions).map((line, index) => {line})} + {entries.map(([cat, stats]) => { const label = (CATEGORY_LABELS[cat] ?? cat).padEnd(12); const parts: string[] = []; @@ -274,6 +277,8 @@ export async function showImportWizard( const label = CATEGORY_LABELS[cat] ?? cat; console.log(` ${label}: ${info?.description ?? 'unknown'}`); } + const { printPermissionPreview } = await import('../index.js'); + await printPermissionPreview(importer, selectedCategories); console.log(''); return; } @@ -325,6 +330,7 @@ export async function showImportWizard( summaryInst.unmount(); } else { console.log(`\nImport complete from ${importer.displayName}`); + if (result.permissions) for (const line of permissionImportLines(result.permissions)) console.log(line); for (const [cat, stats] of result.imported.entries()) { const label = CATEGORY_LABELS[cat] ?? cat; console.log(` ${label}: ${stats.success} imported, ${stats.failed} failed`); diff --git a/src/index.ts b/src/index.ts index 03d668b49..2f09a68db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,7 +62,7 @@ import { resolveCommandOutputFormat, } from './modes/commandOutput.js'; import { AUTOHAND_PATHS, PROJECT_DIR_NAME } from './constants.js'; -import { isSessionWorktreeEnabled, prepareSessionWorktree } from './utils/sessionWorktree.js'; +import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from './utils/sessionWorktree.js'; import { armPromptExitBackstop } from './runtime/promptExitBackstop.js'; import { buildZitSessionInstructions, @@ -528,6 +528,10 @@ program } const normalization = normalizeInitialCliOptions(opts); + if (normalization.invalidOption) { + console.error(chalk.red(normalization.invalidOption)); + process.exit(2); + } if (normalization.deprecatedBrowserOption) { console.warn(chalk.yellow(formatDeprecatedBrowserOptionWarning(normalization.deprecatedBrowserOption))); } @@ -1558,7 +1562,7 @@ program source: source as any, categories: opts.categories as any, all: opts.all, - dryRun: opts.dryRun, + dryRun: opts.dryRun === true || program.opts<{ dryRun?: boolean }>().dryRun === true, retryFailed: opts.retryFailed, }); process.exit(0); @@ -1606,6 +1610,7 @@ async function runCLI(options: InternalCLIOptions): Promise { let commandOutputCompleted = false; // Records the --zit workspace once: in the finally block below, or on process exit. let sessionZitFinalizer: SessionZitFinalizer | null = null; + let sessionWorktree: SessionWorktreeInfo | null = null; const runtimeResourceOwner = new CliRuntimeResourceOwner< AuthUser, VersionCheckResult, @@ -1648,7 +1653,6 @@ async function runCLI(options: InternalCLIOptions): Promise { await applyServedAutohandModel(config, options); const originalWorkspaceRoot = resolveWorkspaceRoot(config, options.path); let workspaceRoot = originalWorkspaceRoot; - let sessionWorktree: ReturnType | null = null; let sessionZit: SessionZitInfo | null = null; // Initialize i18n with locale detection @@ -2303,6 +2307,9 @@ async function runCLI(options: InternalCLIOptions): Promise { await herdrIntegration?.reporter.settled(); herdrIntegration?.detach(); sessionZitFinalizer?.finish(agent?.getLastAssistantResponse()); + if (sessionWorktree && !options.tmux && process.env.AUTOHAND_TMUX_LAUNCHED !== '1') { + console.error(chalk.gray(await cleanupSessionWorktree(sessionWorktree))); + } agentHolder.current = null; restoreConsoleOutput?.(); if (resolveAgentLaunchMode(options) === 'command') { @@ -2733,103 +2740,111 @@ async function runPatchMode(opts: CLIOptions): Promise { await resolveWorkspaceTrust(config, { interactive: false }); } - if (isSessionWorktreeEnabled(opts.worktree)) { - const sessionWorktree = prepareSessionWorktree({ - cwd: originalWorkspaceRoot, - worktree: opts.worktree, - mode: 'patch', - }); - workspaceRoot = sessionWorktree.worktreePath; - const worktreeSafetyCheck = checkWorkspaceSafety(workspaceRoot); - if (!worktreeSafetyCheck.safe) { - printDangerousWorkspaceWarning(workspaceRoot, worktreeSafetyCheck); - process.exit(1); + let sessionWorktree: SessionWorktreeInfo | null = null; + try { + if (isSessionWorktreeEnabled(opts.worktree)) { + sessionWorktree = prepareSessionWorktree({ + cwd: originalWorkspaceRoot, + worktree: opts.worktree, + mode: 'patch', + }); + workspaceRoot = sessionWorktree.worktreePath; + const worktreeSafetyCheck = checkWorkspaceSafety(workspaceRoot); + if (!worktreeSafetyCheck.safe) { + printDangerousWorkspaceWarning(workspaceRoot, worktreeSafetyCheck); + process.exitCode = 1; + return; + } + console.error(chalk.gray(`Using git worktree: ${sessionWorktree.worktreePath}`)); + console.error(chalk.gray(`Branch: ${sessionWorktree.branchName}${sessionWorktree.createdBranch ? ' (new)' : ''}\n`)); } - console.error(chalk.gray(`Using git worktree: ${sessionWorktree.worktreePath}`)); - console.error(chalk.gray(`Branch: ${sessionWorktree.branchName}${sessionWorktree.createdBranch ? ' (new)' : ''}\n`)); - } - const additionalDirs = await resolveAdditionalDirectoriesOrExit(opts.addDir); + const additionalDirs = await resolveAdditionalDirectoriesOrExit(opts.addDir); - // Override model from CLI if provided - if (opts.model) { - config = applyCliModelOverride(config, opts.model, opts.provider); - } - await applyServedAutohandModel(config, opts); - - const { ProviderFactory } = await import('./providers/ProviderFactory.js'); - const { FileActionManager } = await import('./actions/filesystem.js'); - const llmProvider = ProviderFactory.create(config); - const files = new FileActionManager(workspaceRoot, additionalDirs); + // Override model from CLI if provided + if (opts.model) { + config = applyCliModelOverride(config, opts.model, opts.provider); + } + await applyServedAutohandModel(config, opts); - // Enable preview mode - changes will be batched instead of written - const batchId = crypto.randomUUID(); - files.enterPreviewMode(batchId); + const { ProviderFactory } = await import('./providers/ProviderFactory.js'); + const { FileActionManager } = await import('./actions/filesystem.js'); + const llmProvider = ProviderFactory.create(config); + const files = new FileActionManager(workspaceRoot, additionalDirs); - // Set up runtime with auto-confirm and unrestricted mode - const patchOptions: CLIOptions = { - ...opts, - yes: true, // Auto-confirm all actions - unrestricted: true // Skip approval prompts - }; + // Enable preview mode - changes will be batched instead of written + const batchId = crypto.randomUUID(); + files.enterPreviewMode(batchId); - const runtime: AgentRuntime = { - config, - workspaceRoot, - options: patchOptions, - additionalDirs: additionalDirs.length > 0 ? additionalDirs : undefined - }; + // Set up runtime with auto-confirm and unrestricted mode + const patchOptions: CLIOptions = { + ...opts, + yes: true, // Auto-confirm all actions + unrestricted: true // Skip approval prompts + }; - // Show status - console.error(chalk.cyan('Patch Mode: Changes will be captured without modifying files\n')); + const runtime: AgentRuntime = { + config, + workspaceRoot, + options: patchOptions, + additionalDirs: additionalDirs.length > 0 ? additionalDirs : undefined + }; - // Configure web search provider - const { configureSearchFromAccount } = await import('./actions/web.js'); - await configureSearchFromAccount(config, opts.searchEngine); + // Show status + console.error(chalk.cyan('Patch Mode: Changes will be captured without modifying files\n')); - let agent: AutohandAgent | null = null; - let exitCode = 0; - try { - const { AutohandAgent } = await import('./core/agent.js'); - agent = new AutohandAgent(llmProvider, files, runtime); + // Configure web search provider + const { configureSearchFromAccount } = await import('./actions/web.js'); + await configureSearchFromAccount(config, opts.searchEngine); - // Run the instruction (changes will be batched in preview mode) - const succeeded = await agent.runCommandMode(opts.prompt); - if (!succeeded) { - exitCode = 1; - } else { - // Get all pending changes - const changes = files.getPendingChanges(); + let agent: AutohandAgent | null = null; + let exitCode = 0; + try { + const { AutohandAgent } = await import('./core/agent.js'); + agent = new AutohandAgent(llmProvider, files, runtime); - if (changes.length === 0) { - console.error(chalk.yellow('\nNo changes were made.')); + // Run the instruction (changes will be batched in preview mode) + const succeeded = await agent.runCommandMode(opts.prompt); + if (!succeeded) { + exitCode = 1; } else { - // Generate unified patch - const patch = generateUnifiedPatch(changes); - - // Show summary to stderr (so it doesn't pollute stdout when piping) - console.error(chalk.green(`\n✓ ${formatChangeSummary(changes)}`)); - - // Output patch - if (opts.output) { - await fs.default.ensureDir((await import('path')).dirname(opts.output)); - await fs.default.writeFile(opts.output, patch); - console.error(chalk.green(`✓ Patch written to ${opts.output}`)); - console.error(chalk.gray('\nTo apply: git apply ' + opts.output)); + // Get all pending changes + const changes = files.getPendingChanges(); + + if (changes.length === 0) { + console.error(chalk.yellow('\nNo changes were made.')); } else { - // Output to stdout - process.stdout.write(patch); + // Generate unified patch + const patch = generateUnifiedPatch(changes); + + // Show summary to stderr (so it doesn't pollute stdout when piping) + console.error(chalk.green(`\n✓ ${formatChangeSummary(changes)}`)); + + // Output patch + if (opts.output) { + await fs.default.ensureDir((await import('path')).dirname(opts.output)); + await fs.default.writeFile(opts.output, patch); + console.error(chalk.green(`✓ Patch written to ${opts.output}`)); + console.error(chalk.gray('\nTo apply: git apply ' + opts.output)); + } else { + // Output to stdout + process.stdout.write(patch); + } } } + } catch (error) { + console.error(chalk.red(`\nError: ${(error as Error).message}`)); + exitCode = 1; + } finally { + files.exitPreviewMode(); + await agent?.shutdownRuntimeResources(); } - } catch (error) { - console.error(chalk.red(`\nError: ${(error as Error).message}`)); - exitCode = 1; + process.exitCode = exitCode; } finally { - files.exitPreviewMode(); - await agent?.shutdownRuntimeResources(); + if (sessionWorktree && !opts.tmux && process.env.AUTOHAND_TMUX_LAUNCHED !== '1') { + console.error(chalk.gray(await cleanupSessionWorktree(sessionWorktree))); + } } - process.exitCode = exitCode; } /** diff --git a/src/keybindings/profiles.ts b/src/keybindings/profiles.ts index 5a89cce32..068fe3085 100644 --- a/src/keybindings/profiles.ts +++ b/src/keybindings/profiles.ts @@ -203,6 +203,11 @@ export function formatChord(chord: Chord): string { return parts.filter(Boolean).join(' + '); } +export function actionShortcutHint(keybindings: ResolvedKeybindings, action: KeybindingAction, fallback: string): string { + const chord = keybindings.bindings[action][0]; + return chord ? formatChord(chord).replaceAll(' + ', '+') : fallback; +} + export function normalizeChord(chord: Chord): string { return [chord.ctrl ? 'ctrl' : null, chord.meta ? 'meta' : null, chord.shift ? 'shift' : null, chord.key] .filter(Boolean) diff --git a/src/modes/acp/adapter.ts b/src/modes/acp/adapter.ts index 33daf0388..a02c057e3 100644 --- a/src/modes/acp/adapter.ts +++ b/src/modes/acp/adapter.ts @@ -49,7 +49,7 @@ import { loadConfig } from '../../config.js'; import { prepareBareModeConfig } from '../../runtime/bareMode.js'; import type { AgentOutputEvent, AgentRuntime, CLIOptions, LoadedConfig, LLMToolCall } from '../../types.js'; import type { McpServerConfig } from '../../mcp/types.js'; -import { isSessionWorktreeEnabled, prepareSessionWorktree } from '../../utils/sessionWorktree.js'; +import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from '../../utils/sessionWorktree.js'; import { ApiError, classifyApiError, type ApiErrorCode } from '../../providers/errors.js'; import type { SessionMessage } from '../../session/types.js'; import { isGoalFeatureEnabled } from '../../goals/feature.js'; @@ -200,6 +200,7 @@ function parseAssistantReplayParts(content: string): AssistantReplayParts { */ export class AutohandAcpAdapter implements Agent { private sessions = new Map(); + private sessionWorktrees = new Map(); private agents = new Map(); private sessionConfigs = new Map(); private permissionBridges = new Map>(); @@ -396,6 +397,7 @@ export class AutohandAcpAdapter implements Agent { mode: 'acp', }); workspaceRoot = sessionWorktree.worktreePath; + this.sessionWorktrees.set(workspaceRoot, sessionWorktree); process.stderr.write( `[ACP] Using git worktree ${sessionWorktree.worktreePath} (${sessionWorktree.branchName})\n` ); @@ -997,6 +999,13 @@ export class AutohandAcpAdapter implements Agent { this.sessionConfigOptions.clear(); this.cancelledSessions.clear(); await Promise.all(agents.map((agent) => this.shutdownAgent(agent))); + await this.cleanupWorktrees(); + } + + private async cleanupWorktrees(): Promise { + const worktrees = [...this.sessionWorktrees.values()]; + this.sessionWorktrees.clear(); + for (const worktree of worktrees) process.stderr.write(`${await cleanupSessionWorktree(worktree)}\n`); } private async shutdownAgent(agent: AutohandAgent): Promise { @@ -1351,6 +1360,7 @@ export class AutohandAcpAdapter implements Agent { // The hooks above already ended the session; only the MCP servers, relays // and background processes each agent still owns need releasing. await Promise.all(agents.map((agent) => agent.shutdownRuntimeResources().catch(() => {}))); + await this.cleanupWorktrees(); } private async emitHookSafe(method: string, params: Record): Promise { diff --git a/src/modes/planMode/PlanModeManager.ts b/src/modes/planMode/PlanModeManager.ts index 5315469d6..9fda38272 100644 --- a/src/modes/planMode/PlanModeManager.ts +++ b/src/modes/planMode/PlanModeManager.ts @@ -179,6 +179,13 @@ export class PlanModeManager extends EventEmitter { this.emit('execution:started', this.state.plan); } + completeExecution(): void { + if (this.state.phase !== 'executing') return; + this.state.phase = 'completed'; + this.disable(); + this.emit('execution:completed', this.state.plan); + } + /** * Get list of read-only tools allowed in plan mode */ diff --git a/src/modes/rpc/index.ts b/src/modes/rpc/index.ts index 32ec70ffa..ea2a34067 100644 --- a/src/modes/rpc/index.ts +++ b/src/modes/rpc/index.ts @@ -21,7 +21,7 @@ import { buildPermissionSettingsFromYolo, } from '../../permissions/yoloMode.js'; import type { CLIOptions, AgentRuntime, ClientContext, LoadedConfig } from '../../types.js'; -import { isSessionWorktreeEnabled, prepareSessionWorktree } from '../../utils/sessionWorktree.js'; +import { cleanupSessionWorktree, isSessionWorktreeEnabled, prepareSessionWorktree, type SessionWorktreeInfo } from '../../utils/sessionWorktree.js'; import type { JsonRpcRequest, JsonRpcResponse, @@ -75,7 +75,7 @@ import { writeInternalError, } from './protocol.js'; import { getPlanModeManager } from '../../commands/plan.js'; -import { getRpcErrorMetadata, writeRpcDebugLine } from './logging.js'; +import { getRpcErrorMetadata, writeRpcDebugLine, writeRpcSessionNotice } from './logging.js'; import { shutdownBrowserToolBridge } from '../../browser/browserToolBridge.js'; import { configureSearchFromAccount } from '../../actions/web.js'; import { @@ -498,6 +498,7 @@ export async function runRpcMode(options: CLIOptions): Promise<0 | 1> { let adapter: RPCAdapter | null = null; let agent: AutohandAgent | null = null; + let sessionWorktree: SessionWorktreeInfo | null = null; let reader: LineReader | null = null; let exitCode = 0; let shutdownReason: RpcShutdownReason = 'error'; @@ -601,7 +602,7 @@ export async function runRpcMode(options: CLIOptions): Promise<0 | 1> { config.ui.useInkRenderer = false; if (isSessionWorktreeEnabled(options.worktree)) { - const sessionWorktree = prepareSessionWorktree({ + sessionWorktree = prepareSessionWorktree({ cwd: originalWorkspaceRoot, worktree: options.worktree, mode: 'rpc', @@ -802,6 +803,7 @@ export async function runRpcMode(options: CLIOptions): Promise<0 | 1> { await Promise.resolve().then(() => shutdownBrowserToolBridge()).catch(() => {}); await shutdownRpcRuntime(adapter, agent, shutdownReason).catch(() => {}); + if (sessionWorktree) writeRpcSessionNotice(await cleanupSessionWorktree(sessionWorktree)); await flushRpcOutput(); process.stdout.off('error', handleStdoutError); restoreConsole(); diff --git a/src/modes/rpc/logging.ts b/src/modes/rpc/logging.ts index 6119e3384..3e87f1774 100644 --- a/src/modes/rpc/logging.ts +++ b/src/modes/rpc/logging.ts @@ -26,6 +26,10 @@ export function writeRpcDebugLine(message: string): void { writeAutohandDebugLine(`[RPC DEBUG] ${message}`); } +export function writeRpcSessionNotice(message: string): void { + process.stderr.write(`${message}\n`); +} + /** * Describe an error without exposing its message, stack, or attached payloads. */ diff --git a/src/modes/teammate.ts b/src/modes/teammate.ts index ef2acf749..4717f81b5 100644 --- a/src/modes/teammate.ts +++ b/src/modes/teammate.ts @@ -121,7 +121,9 @@ async function executeTaskWithEnvironment( workspaceRoot: workspacePath, options: { clientContext: 'cli' }, }; - const toolsRegistry = createToolsRegistry(workspacePath); + const toolsRegistry = createToolsRegistry(workspacePath, undefined, { + projectToolsTrusted: config.workspaceTrust?.trusted === true, + }); // Teammates read the same skills as the lead, bound to the same account // profile, with their own activation state. const skillsRegistry = new SkillsRegistry(AUTOHAND_PATHS.skills, 'autohand-user', { accountConfigPath: config.configPath }); diff --git a/src/permissions/PermissionManager.ts b/src/permissions/PermissionManager.ts index 08b07abae..05d29927e 100644 --- a/src/permissions/PermissionManager.ts +++ b/src/permissions/PermissionManager.ts @@ -2,6 +2,8 @@ * Permission Manager - Handles tool/command approval with allow/deny lists * @license Apache-2.0 */ +import { matchesImportedClaudeRule, type ImportedClaudeRule } from './importedClaudeRules.js'; +import { isSimplePermissionCommand } from './commandParts.js'; import { getCommandPrefix } from './types.js'; import type { PermissionSettings, @@ -37,6 +39,11 @@ const READ_ONLY_REVIEW_TOOLS = new Set([ 'git_status', 'git_list_untracked', 'git_diff', 'git_diff_range', 'git_log', 'checksum', ]); +const PLAN_FILE_EDIT_TOOLS = new Set([ + 'write_file', 'append_file', 'apply_patch', 'notebook_edit', 'search_replace', + 'format_file', 'multi_file_edit', 'delete_path', 'rename_path', 'copy_path', +]); + /** * Default security blacklist - always blocked patterns for sensitive files and dangerous commands. * These are merged with user settings and cannot be overridden by allowLists. @@ -194,6 +201,7 @@ export class PermissionManager { private settings: PermissionSettings; private localSettings: PermissionSettings | undefined; + private workspaceSettings: PermissionSettings[] = []; private sessionProjectSettings: SessionProjectPermissions | undefined; private sessionCache: Map = new Map(); private mode: PermissionMode; @@ -216,6 +224,7 @@ export class PermissionManager { private normalizeSettings(settings: PermissionSettings | undefined): PermissionSettings { return { mode: settings?.mode ?? 'interactive', + importedClaudeRules: [...(settings?.importedClaudeRules ?? [])], allowList: [...(settings?.allowList ?? settings?.whitelist ?? [])], denyList: [...(settings?.denyList ?? settings?.blacklist ?? [])], rules: [...(settings?.rules ?? [])], @@ -237,6 +246,7 @@ export class PermissionManager { this.workspaceRoot = isOptions ? (options as PermissionManagerOptions).workspaceRoot : undefined; this.settings = this.normalizeSettings(settings); + this.workspaceSettings = (settings.workspaceScopes ?? []).map(scope => this.normalizeSettings(scope)); this.mode = this.settings.mode || 'interactive'; } @@ -335,7 +345,7 @@ export class PermissionManager { private getMergedSettings(): PermissionSettings { return this.extensionPolicies.reduce( (settings, policy) => mergePermissions(settings, policy.settings), - mergePermissions(this.settings, this.localSettings), + mergePermissions(this.workspaceSettings.reduce((merged, scope) => mergePermissions(merged, scope), this.settings), this.localSettings), ); } @@ -392,9 +402,29 @@ export class PermissionManager { return { allowed: false, reason: 'mode_restricted' }; } + if ((context.tool === 'run_command' || context.tool === 'shell') && context.command + && !isSimplePermissionCommand(this.getFullCommand(context)) + && this.getMergedSettings().importedClaudeRules?.some(rule => rule.pattern.startsWith(context.tool + ':'))) { + const merged = this.getMergedSettings(); + const relevant = (pattern: string) => !pattern.includes(':') || pattern.startsWith(context.tool + ':') || pattern.startsWith('*:'); + const allows = [...(merged.allowList ?? []), ...(this.sessionProjectSettings?.allowList ?? [])]; + const denies = [...(merged.denyList ?? []), ...(this.sessionProjectSettings?.denyList ?? [])]; + if (allows.some(relevant) || denies.some(relevant)) { + // Unsupported shell syntax cannot soften hard restrictions into a prompt. + const hard = this.checkPatterns(context); + if (hard && !hard.allowed) return hard; + if (this.mode === 'restricted') return { allowed: false, reason: 'mode_restricted' }; + if (denies.some(relevant)) return { allowed: false, reason: 'deny_list' }; + return { allowed: false, reason: 'default' }; + } + } + // Pattern-based checks (AFTER security blacklist, BEFORE session cache) const patternDecision = this.checkPatterns(context); - if (patternDecision && (!this.readOnlyReview || !patternDecision.allowed)) { + const requestedTool = context.requestedTool ?? context.tool; + const planFileEdit = this.planEditApproval !== undefined && PLAN_FILE_EDIT_TOOLS.has(requestedTool); + const planOperation = this.hasPlanPermissionPolicy(context); + if (patternDecision && (!patternDecision.allowed || !planOperation) && (!this.readOnlyReview || !patternDecision.allowed)) { return patternDecision; } @@ -433,6 +463,14 @@ export class PermissionManager { if (this.readOnlyReview) return { allowed: true, reason: 'read_only_review' }; + if (planFileEdit) { + if (this.mode === 'restricted') return { allowed: false, reason: 'mode_restricted' }; + return this.planEditApproval === 'auto' + ? { allowed: true, reason: 'user_approved' } + : { allowed: false, reason: 'default' }; + } + if (planOperation) return { allowed: false, reason: this.mode === 'restricted' ? 'mode_restricted' : 'default' }; + if (cachedDecision === true) { return { allowed: true, reason: 'user_approved', cached: true }; } @@ -460,17 +498,24 @@ export class PermissionManager { context, this.localSettings?.allowList, this.localSettings?.denyList, - 'project' + 'project', + this.localSettings?.importedClaudeRules ); if (projectDecision) { return projectDecision; } + for (const scope of [...this.workspaceSettings].reverse()) { + const decision = this.checkScopedLists(context, scope.allowList, scope.denyList, 'project', scope.importedClaudeRules); + if (decision) return decision; + } + const userDecision = this.checkScopedLists( context, this.settings.allowList, this.settings.denyList, - 'user' + 'user', + this.settings.importedClaudeRules ); if (userDecision) { return userDecision; @@ -496,6 +541,24 @@ export class PermissionManager { return { allowed: false, reason: 'default' }; } + private planEditApproval: 'auto' | 'manual' | undefined; + + setPlanEditApproval(approval: 'auto' | 'manual' | undefined): void { + this.planEditApproval = approval; + } + + hasPlanPermissionPolicy(context: PermissionContext): boolean { + return this.planEditApproval !== undefined && ( + PLAN_FILE_EDIT_TOOLS.has(context.requestedTool ?? context.tool) + || context.tool === 'write_file' || context.tool === 'run_command' || context.tool === 'shell' + ); + } + + requiresPlanApproval(context: PermissionContext): boolean { + return this.hasPlanPermissionPolicy(context) + && !(this.planEditApproval === 'auto' && PLAN_FILE_EDIT_TOOLS.has(context.requestedTool ?? context.tool)); + } + /** * Record a user's decision using the legacy boolean API. * Approved permissions are saved to the project allowList when possible. @@ -743,11 +806,13 @@ export class PermissionManager { } } + // Imported and existing deny lists must also beat broad allowPatterns and path grants. + const scopedDenial = this.checkScopedDenials(context) ?? this.checkDenyRules(context); // 4. allowPatterns – explicitly allowed if (settings.allowPatterns?.length) { for (const p of settings.allowPatterns) { if (matchesToolPattern(p, call)) { - return { allowed: true, reason: 'pattern_allowed' }; + return scopedDenial ?? { allowed: true, reason: 'pattern_allowed' }; } } } @@ -755,12 +820,12 @@ export class PermissionManager { // 5. allPathsAllowed – allow any file-path tool const fileTools = new Set(['read_file', 'write_file', 'list_dir', 'delete_path', 'move_path', 'copy_path']); if (settings.allPathsAllowed && fileTools.has(context.tool)) { - return { allowed: true, reason: 'all_paths_allowed' }; + return scopedDenial ?? { allowed: true, reason: 'all_paths_allowed' }; } // 6. allUrlsAllowed – allow url tool if (settings.allUrlsAllowed && context.tool === 'url') { - return { allowed: true, reason: 'all_urls_allowed' }; + return scopedDenial ?? { allowed: true, reason: 'all_urls_allowed' }; } return null; @@ -777,23 +842,29 @@ export class PermissionManager { /** * Check scoped allow/deny lists, returning a source-specific decision if matched. */ + private matchesScopedPattern(context: PermissionContext, pattern: string, action: 'allow' | 'deny', importedRules?: ImportedClaudeRule[]): boolean { + const imported = importedRules?.find(rule => rule.pattern === pattern && rule.action === action); + return imported ? matchesImportedClaudeRule(context, imported, this.workspaceRoot) : this.matchesPattern(context, pattern); + } + private checkScopedLists( context: PermissionContext, allowList: string[] | undefined, denyList: string[] | undefined, - scope: 'session' | 'project' | 'user' + scope: 'session' | 'project' | 'user', + importedRules?: ImportedClaudeRule[] ): PermissionDecision | null { const normalizedAllowList = allowList ?? []; const normalizedDenyList = denyList ?? []; - if (normalizedDenyList.some(pattern => this.matchesPattern(context, pattern))) { + if (normalizedDenyList.some(pattern => this.matchesScopedPattern(context, pattern, 'deny', importedRules))) { return { allowed: false, reason: scope === 'user' ? 'deny_list' : `${scope}_deny_list` as PermissionDecision['reason'], }; } - if (normalizedAllowList.some(pattern => this.matchesPattern(context, pattern))) { + if (normalizedAllowList.some(pattern => this.matchesScopedPattern(context, pattern, 'allow', importedRules))) { return { allowed: true, reason: scope === 'user' ? 'allow_list' : `${scope}_allow_list` as PermissionDecision['reason'], @@ -811,16 +882,23 @@ export class PermissionManager { }, { denyList: this.localSettings?.denyList, + importedRules: this.localSettings?.importedClaudeRules, reason: 'project_deny_list' as const, }, + ...[...this.workspaceSettings].reverse().map(scope => ({ + denyList: scope.denyList, + importedRules: scope.importedClaudeRules, + reason: 'project_deny_list' as const, + })), { denyList: this.settings.denyList, + importedRules: this.settings.importedClaudeRules, reason: 'deny_list' as const, }, ]; for (const scope of scopes) { - if (scope.denyList?.some((pattern) => this.matchesPattern(context, pattern))) { + if (scope.denyList?.some((pattern) => this.matchesScopedPattern(context, pattern, 'deny', scope.importedRules))) { return { allowed: false, reason: scope.reason }; } } @@ -1026,6 +1104,7 @@ export class PermissionManager { const index = this.settings.allowList.indexOf(pattern); if (index !== -1) { this.settings.allowList.splice(index, 1); + this.settings.importedClaudeRules = this.settings.importedClaudeRules?.filter(rule => rule.pattern !== pattern || rule.action !== 'allow'); if (this.onPersist) { await this.onPersist(this.settings); } @@ -1042,6 +1121,7 @@ export class PermissionManager { const index = this.settings.denyList.indexOf(pattern); if (index !== -1) { this.settings.denyList.splice(index, 1); + this.settings.importedClaudeRules = this.settings.importedClaudeRules?.filter(rule => rule.pattern !== pattern || rule.action !== 'deny'); if (this.onPersist) { await this.onPersist(this.settings); } diff --git a/src/permissions/commandParts.ts b/src/permissions/commandParts.ts new file mode 100644 index 000000000..020857284 --- /dev/null +++ b/src/permissions/commandParts.ts @@ -0,0 +1,9 @@ +/** + * Only literal simple commands have equivalent prefix permission semantics. + * Other shell syntax must never gain approval from an imported prefix glob. + */ +export function isSimplePermissionCommand(command: string): boolean { + if (!/^[A-Za-z0-9_./-]+(?: [A-Za-z0-9_./:=,@+ -]*)?$/.test(command)) return false; + const executable = command.split(' ', 1)[0].split('/').at(-1)!; + return !/^(?:sh|bash|zsh|dash|fish|ksh|csh|tcsh|eval|exec|env|command|builtin|sudo|doas|nohup|xargs|if|then|else|fi|for|while|until|case|do|done|function|timeout|time|nice|stdbuf|noglob|watch|setsid|ionice|flock|find|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|wsl|wsl.exe|start|call)$/i.test(executable); +} diff --git a/src/permissions/importedClaudeRules.ts b/src/permissions/importedClaudeRules.ts new file mode 100644 index 000000000..38033b765 --- /dev/null +++ b/src/permissions/importedClaudeRules.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; +import { lstatSync, realpathSync } from 'node:fs'; +import type { PermissionContext } from './types.js'; + +/** Matching semantics retained for newly imported rules; existing globs keep their meaning. */ +export interface ImportedClaudeRule { + pattern: string; + action: 'allow' | 'deny'; + kind: 'command-prefix' | 'command-exact' | 'file' | 'tool'; +} + +/** Resolve existing parents too, so writes to a new file cannot bypass an alias. */ +function resolvedPath(file: string): string | null { + let parent = file; + const missing: string[] = []; + while (true) { + try { return path.join(realpathSync.native(parent), ...missing); } + catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code !== 'ENOENT' && code !== 'ENOTDIR') return null; + try { if (lstatSync(parent).isSymbolicLink()) return null; } catch { /* A missing child is resolved against its nearest existing parent. */ } + const next = path.dirname(parent); + if (next === parent) return null; + missing.unshift(path.basename(parent)); + parent = next; + } + } +} + +export function matchesImportedClaudeRule(context: PermissionContext, rule: ImportedClaudeRule, workspaceRoot?: string): boolean { + const colon = rule.pattern.indexOf(':'); + if (colon < 0 || rule.pattern.slice(0, colon) !== context.tool) return false; + const target = rule.pattern.slice(colon + 1); + if (rule.kind === 'tool') return target === '*'; + if (rule.kind === 'file') { + if (!context.path) return false; + const root = path.resolve(workspaceRoot ?? process.cwd()); + const realRoot = resolvedPath(root); + if (!realRoot) return rule.action === 'deny'; + const canonical = (value: string) => { + const full = path.resolve(root, value); + return full === root || full.startsWith(root + path.sep) ? path.resolve(realRoot, path.relative(root, full)) : full; + }; + const requested = canonical(context.path); + const resolved = resolvedPath(requested); + if (!resolved) return rule.action === 'deny'; + const matches = (value: string) => { + if (target === '**' || target.endsWith('/**')) { + const directory = canonical(target === '**' ? '.' : target.slice(0, -3)); + return value === directory || value.startsWith(directory + path.sep); + } + return value === canonical(target); + }; + return rule.action === 'deny' ? matches(requested) || matches(resolved) : matches(requested) && matches(resolved); + } + if (!context.command) return false; + const command = [context.command, ...(context.args ?? [])].join(' '); + if (rule.kind === 'command-exact') return command === target; + const prefix = target.endsWith('*') ? target.slice(0, -1) : target; + return command === prefix || command.startsWith(prefix + ' '); +} diff --git a/src/permissions/localProjectPermissions.ts b/src/permissions/localProjectPermissions.ts index a77f8dd23..f18d7b4db 100644 --- a/src/permissions/localProjectPermissions.ts +++ b/src/permissions/localProjectPermissions.ts @@ -182,6 +182,7 @@ export function mergePermissions( ...(normalizedGlobal.denyList || []), ...(normalizedLocal.denyList || []) ].filter((v, i, a) => a.indexOf(v) === i), + importedClaudeRules: [...(normalizedGlobal.importedClaudeRules ?? []), ...(normalizedLocal.importedClaudeRules ?? [])], // Merge rules (local rules checked first) rules: [ ...(normalizedLocal.rules || []), diff --git a/src/permissions/types.ts b/src/permissions/types.ts index 735951cac..747cfe9b9 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -16,6 +16,10 @@ export interface PermissionRule { } export interface PermissionSettings { + /** Runtime workspace layer. Kept separate so source-rule semantics cannot shadow user denies. */ + workspaceScopes?: PermissionSettings[]; + /** Source semantics for rules added by the Claude permission importer. */ + importedClaudeRules?: import('./importedClaudeRules.js').ImportedClaudeRule[]; /** Permission mode: interactive (default), unrestricted (no prompts), restricted (deny all dangerous), external (use callback) */ mode?: PermissionMode; /** Commands/tools that never require approval */ diff --git a/src/permissions/workspaceTrust.ts b/src/permissions/workspaceTrust.ts index 02c725292..debf4d9a5 100644 --- a/src/permissions/workspaceTrust.ts +++ b/src/permissions/workspaceTrust.ts @@ -2,26 +2,51 @@ * Workspace trust for repository-supplied code. * * Hooks and MCP servers declared in `/.autohand/` run commands as - * soon as a session starts. A cloned repository can ship those files, so they - * only apply after the user trusts the workspace for their exact content. Any - * change to the declared hooks or servers produces a new fingerprint and asks - * again. + * soon as a session starts, and tool files under `/.autohand/tools/` + * run commands when the model calls them. A cloned repository can ship those + * files, so they only apply after the user trusts the workspace for their + * exact content. Any change to the declared hooks, servers, or tool files + * produces a new fingerprint and asks again. * * @license Apache-2.0 */ import crypto from 'node:crypto'; import fs from 'fs-extra'; import path from 'node:path'; -import { AUTOHAND_FILES } from '../constants.js'; -import type { HookDefinition, McpServerConfigEntry } from '../types.js'; +import { AUTOHAND_FILES, PROJECT_DIR_NAME } from '../constants.js'; +import type { HookDefinition, McpServerConfigEntry, WorkspaceTrustToolEntry } from '../types.js'; import { atomicWriteFile, withFileLock } from '../utils/atomicFile.js'; +export type { WorkspaceTrustToolEntry } from '../types.js'; + const STORE_VERSION = 1; /** Executable entries a workspace contributes through its project files. */ export interface WorkspaceTrustEntries { hooks: HookDefinition[]; mcpServers: McpServerConfigEntry[]; + tools: WorkspaceTrustToolEntry[]; +} + +/** + * Hash every `/.autohand/tools/*.json` file, sorted by name, so the + * trust fingerprint changes whenever a tool file is added, removed, or edited. + */ +export async function collectProjectToolEntries(workspaceRoot: string): Promise { + const dir = path.join(workspaceRoot, PROJECT_DIR_NAME, 'tools'); + if (!(await fs.pathExists(dir))) return []; + const files = (await fs.readdir(dir)) + .filter((file) => file.endsWith('.json')) + .sort((a, b) => a.localeCompare(b)); + const entries: WorkspaceTrustToolEntry[] = []; + for (const file of files) { + const bytes = await fs.readFile(path.join(dir, file)); + entries.push({ + file: path.posix.join(PROJECT_DIR_NAME, 'tools', file), + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + }); + } + return entries; } interface TrustedWorkspaceRecord { @@ -54,7 +79,7 @@ export function canonicalJson(value: unknown): string { export function computeWorkspaceTrustFingerprint(entries: WorkspaceTrustEntries): string { return crypto .createHash('sha256') - .update(canonicalJson({ hooks: entries.hooks, mcpServers: entries.mcpServers })) + .update(canonicalJson({ hooks: entries.hooks, mcpServers: entries.mcpServers, tools: entries.tools ?? [] })) .digest('hex'); } diff --git a/src/providers/AutohandAIProvider.ts b/src/providers/AutohandAIProvider.ts index d7e194ec0..50c0d9c85 100644 --- a/src/providers/AutohandAIProvider.ts +++ b/src/providers/AutohandAIProvider.ts @@ -34,13 +34,14 @@ export function getAutohandAICloudModelCliUnsupportedReason( model: string | undefined, ): string | undefined { const normalized = normalizeAutohandAICloudModelId(model); + if (normalized === "weka") return "Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model."; const entry = getProviderModelOptions("autohandai").find( (candidate) => candidate.id === normalized, ); if (entry?.cliSupported !== false) return undefined; const name = entry.id === "weka" ? "Weka" : (entry.displayName ?? entry.id); - return `${name} is available through the Autohand API and Console Playground. CLI execution is not supported yet.`; + return `${name} is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.`; } export interface AutohandAICloudModelDefinition { diff --git a/src/providers/LLMGatewayClient.ts b/src/providers/LLMGatewayClient.ts index ff4b32355..091bb6016 100644 --- a/src/providers/LLMGatewayClient.ts +++ b/src/providers/LLMGatewayClient.ts @@ -114,6 +114,7 @@ interface StructuredGatewayError { scope?: string; resetAt?: number; upgradeUrl?: string; + resetUrl?: string; } function structuredGatewayError(value: unknown): StructuredGatewayError | undefined { @@ -132,6 +133,7 @@ function structuredGatewayError(value: unknown): StructuredGatewayError | undefi ? { resetAt: error.resetAt } : {}), ...(typeof error.upgradeUrl === "string" ? { upgradeUrl: error.upgradeUrl } : {}), + ...(typeof error.resetUrl === "string" ? { resetUrl: error.resetUrl } : {}), }; } @@ -230,10 +232,28 @@ function buildAutohandQuotaMessage(error: StructuredGatewayError): string { const upgradeMessage = upgradeUrl ? `\nUpgrade your Autohand Code plan for more usage: ${upgradeUrl}` : ""; + const resetUrl = ['window_5h', 'window_24h', 'window_week'].includes(error.scope ?? '') + ? trustedAutohandResetUrl(error.resetUrl) + : undefined; + const resetMessage = resetUrl + ? `\nSkip the wait: use 100 reset credits to reset this window now. Packs start at US$10: ${resetUrl}` + : ''; return `Autohand AI ${AUTOHAND_QUOTA_SCOPE_LABELS[scope]} reached.` + `\n${error.message ?? "Your current request quota is exhausted."}` + `\n${formatAutohandQuotaReset(error.resetAt)}` - + upgradeMessage; + + upgradeMessage + + resetMessage; +} + +function trustedAutohandResetUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + return url.origin === 'https://console.autohand.ai' && url.pathname === '/billing' + && !url.username && !url.password && !url.hash ? url.toString() : undefined; + } catch { + return undefined; + } } function buildAutohandTokenThroughputMessage(error: StructuredGatewayError): string { diff --git a/src/providers/OpenRouterClient.ts b/src/providers/OpenRouterClient.ts index 46fa9fd95..56ad28393 100644 --- a/src/providers/OpenRouterClient.ts +++ b/src/providers/OpenRouterClient.ts @@ -76,6 +76,18 @@ function sanitizeMessages( } const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"; + +/** + * App attribution OpenRouter reads for its public app rankings. HTTP-Referer creates the app, + * X-OpenRouter-Title names it (X-Title is the older alias), and cli-agent is a recognised category. + */ +export const OPENROUTER_ATTRIBUTION_HEADERS = { + "HTTP-Referer": "https://autohand.ai", + "X-OpenRouter-Title": "Autohand Code", + "X-Title": "Autohand Code", + "X-OpenRouter-Categories": "cli-agent", +} as const; + const DEFAULT_MAX_RETRIES = 3; const MAX_ALLOWED_RETRIES = 5; const DEFAULT_RETRY_DELAY = 1000; @@ -292,9 +304,7 @@ export class OpenRouterClient { const headers: Record = { "Content-Type": "application/json", - "HTTP-Referer": "https://autohand.dev", - "X-OpenRouter-Title": "Autohand Code CLI", - "X-OpenRouter-Categories": "cli-agent", + ...OPENROUTER_ATTRIBUTION_HEADERS, }; if (this.apiKey) { headers.Authorization = `Bearer ${this.apiKey}`; diff --git a/src/providers/models.json b/src/providers/models.json index 3f4100a36..dd4508b1b 100644 --- a/src/providers/models.json +++ b/src/providers/models.json @@ -39,24 +39,17 @@ {"id": "qwen3.8-27b", "displayName": "qwen3.8-27b", "description": "Pro+, Max, Team and Enterprise", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true}, {"id": "muse-wonder-1.0", "displayName": "Muse Wonder 1.0", "description": "Lite and above", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high", "xhigh"]}, {"id": "qwen3.8-max", "displayName": "Qwen 3.8 Max", "description": "Lite and above", "contextWindow": 262144, "maxTokens": 16000, "toolCalls": true}, - {"id": "gemini-4-argon", "displayName": "Gemini 4 Argon", "description": "Max and Enterprise", "contextWindow": 1048576, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high"]}, - { - "id": "weka", - "displayName": "Weka (API + Console only)", - "description": "Typed decision model for noul, choice, and score questions; not runnable in the CLI", - "api": "autohand-decisions", - "endpoint": "/decisions", - "contextWindow": 32000, - "maxTokens": 1024, - "toolCalls": false, - "cliSupported": false - } + {"id": "gemini-4-argon", "displayName": "Gemini 4 Argon", "description": "Max, Team and Enterprise", "contextWindow": 1048576, "maxTokens": 16000, "toolCalls": true, "reasoningEffort": "high", "reasoningEfforts": ["low", "medium", "high"]} ] }, "openrouter": { - "defaultModel": "anthropic/claude-sonnet-5", - "runtimeDefaultModel": "anthropic/claude-sonnet-5", + "defaultModel": "anthropic/claude-sonnet-5-5", + "runtimeDefaultModel": "anthropic/claude-sonnet-5-5", "models": [ + {"id": "anthropic/claude-sonnet-5-5", "displayName": "Claude Sonnet 5.5"}, + {"id": "anthropic/claude-opus-5-5", "displayName": "Claude Opus 5.5"}, + {"id": "anthropic/claude-haiku-5-5", "displayName": "Claude Haiku 5.5"}, + {"id": "anthropic/claude-fable-5-1", "displayName": "Claude Fable 5.1"}, { "id": "openrouter/auto", "displayName": "OpenRouter Auto" }, { "id": "anthropic/claude-opus-5-fast", "displayName": "Claude Opus 5 (Fast)", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, { "id": "anthropic/claude-opus-5", "displayName": "Claude Opus 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, @@ -83,9 +76,13 @@ ] }, "anthropic": { - "defaultModel": "claude-sonnet-5", - "runtimeDefaultModel": "claude-sonnet-5", + "defaultModel": "claude-sonnet-5-5", + "runtimeDefaultModel": "claude-sonnet-5-5", "models": [ + {"id": "claude-sonnet-5-5", "displayName": "Claude Sonnet 5.5"}, + {"id": "claude-opus-5-5", "displayName": "Claude Opus 5.5"}, + {"id": "claude-haiku-5-5", "displayName": "Claude Haiku 5.5"}, + {"id": "claude-fable-5-1", "displayName": "Claude Fable 5.1"}, { "id": "claude-opus-5", "displayName": "Claude Opus 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, { "id": "claude-sonnet-5", "displayName": "Claude Sonnet 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, { "id": "claude-fable-5", "displayName": "Claude Fable 5", "contextWindow": 1000000, "maxTokens": 128000, "reasoningEffort": "high" }, @@ -112,9 +109,11 @@ "defaultModel": "gpt-5.4", "runtimeDefaultModel": "gpt-5.4", "models": [ - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", + { "id": "gpt-5.6-sol", "contextWindow": 272000 }, + { "id": "gpt-5.6-terra", "contextWindow": 272000 }, + { "id": "gpt-5.6-luna", "contextWindow": 272000 }, + { "id": "gpt-6-astra", "contextWindow": 272000 }, + { "id": "gpt-6.1-sol", "contextWindow": 272000 }, "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", diff --git a/src/search/managedWebSearch.ts b/src/search/managedWebSearch.ts index 944589f2c..f879b3972 100644 --- a/src/search/managedWebSearch.ts +++ b/src/search/managedWebSearch.ts @@ -19,6 +19,12 @@ export function managedSearchIdentity(config: SearchAccountConfig): ManagedSearc } const input = z.object({ query: z.string().trim().min(1).max(1024), limit: z.number().int().min(1).max(10) }); +const capability = z.object({ available: z.boolean(), provider: z.literal('cloudflare'), tier: z.string(), usage: z.object({ + limit: z.literal(10), remaining: z.number().int().min(0).max(10), resetAt: z.string().datetime(), +}).nullable().optional() }); +export class ManagedSearchCapError extends Error { + constructor(message: string) { super(message); this.name = 'ManagedSearchCapError'; } +} const result = z.object({ provider: z.literal('cloudflare'), query: z.string().max(1024), results: z.array(z.object({ title: z.string().max(10_000), url: z.string().url().refine(url => /^https?:\/\//.test(url)), snippet: z.string().max(60_000), })).max(10) }); @@ -81,26 +87,34 @@ export class ManagedWebSearch { ...(body ? { body: JSON.stringify(body) } : {}), }); if (signal.aborted || !response.ok) { - discardResponseBody(response); + if (!signal.aborted && response.status === 429 && response.headers.get('Content-Type')?.includes('application/json')) { + let body: unknown; + try { body = await readJson(response, signal); } catch { signal.throwIfAborted(); } + const problem = z.object({ error: z.object({ code: z.literal('web_search_cap_hit'), message: z.string().min(1).max(500).refine(text => !/[\r\n]/.test(text)) }) }).safeParse(body); + if (problem.success) throw new ManagedSearchCapError(problem.data.error.message); + } else discardResponseBody(response); signal.throwIfAborted(); const advice = response.status === 401 ? 'Sign in again.' - : response.status === 402 ? 'Included search requires Lite or above. Configure your own provider with /search on Free.' + : response.status === 402 ? 'Verify your email or configure your own provider with /search.' : response.status === 429 ? 'Wait a moment before searching again.' : 'Try again later or choose a provider with /search.'; throw new Error(`Autohand Web Search failed (${response.status}). ${advice}`); } return readJson(response, signal); } - async available(signal?: AbortSignal): Promise { + async status(signal?: AbortSignal): Promise | null> { try { - const capability = z.object({ available: z.boolean(), provider: z.literal('cloudflare'), tier: z.string() }).parse(await this.request('GET', undefined, signal)); - return capability.available; + return capability.parse(await this.request('GET', undefined, signal)); } catch (error) { if (signal?.aborted) throw error; - return false; + return null; } } + async available(signal?: AbortSignal): Promise { + return (await this.status(signal))?.available ?? false; + } + async search(query: string, limit: number, signal?: AbortSignal): Promise { return result.parse(await this.request('POST', input.parse({ query, limit }), signal)).results; } diff --git a/src/search/providers.ts b/src/search/providers.ts index 3c76cae1c..8b0f622f9 100644 --- a/src/search/providers.ts +++ b/src/search/providers.ts @@ -2,7 +2,7 @@ import type { SearchProvider } from '../types.js'; export const SEARCH_PROVIDERS = ['autohand', 'browser-profile', 'exa', 'google', 'brave', 'duckduckgo', 'parallel'] as const satisfies readonly SearchProvider[]; export const SEARCH_PROVIDER_OPTIONS = [ - { value: 'autohand', label: 'Autohand Web Search', description: 'Included with Lite and above; uses your signed-in account' }, + { value: 'autohand', label: 'Autohand Web Search', description: 'Free: 10 web searches a day; Lite and above: included' }, { value: 'browser-profile', label: 'Browser Profile', description: 'Uses connected Chromium or your Chrome/Brave profile; no API key' }, { value: 'exa', label: 'Exa.ai', description: 'Requires your Exa API key' }, { value: 'brave', label: 'Brave Search', description: 'Requires your Brave API key' }, diff --git a/src/session/peers/CommandCoordinationGate.ts b/src/session/peers/CommandCoordinationGate.ts index 5d5559e9b..07ee25246 100644 --- a/src/session/peers/CommandCoordinationGate.ts +++ b/src/session/peers/CommandCoordinationGate.ts @@ -73,11 +73,11 @@ export function signalCoordinatedProcess(child: ChildProcess, signal: NodeJS.Sig child.kill(signal); } -export async function spawnCoordinatedProcess(command: ResourceCommand, options: SpawnOptions = {}, signal?: AbortSignal): Promise { +export async function spawnCoordinatedProcess(command: ResourceCommand, options: SpawnOptions = {}, signal?: AbortSignal, execution: Pick = command): Promise { const coordinated = await prepareCommandCoordination(command, signal); try { signal?.throwIfAborted(); - const child = spawn(command.file, command.args, { ...options, cwd: command.cwd, ...(coordinated ? { detached: true } : {}) }); + const child = spawn(execution.file, execution.args, { ...options, cwd: command.cwd, ...(coordinated ? { detached: true } : {}) }); coordinated?.observe(child); return child; } catch (error) { diff --git a/src/startup/cliOptions.ts b/src/startup/cliOptions.ts index f3912399c..468ff7155 100644 --- a/src/startup/cliOptions.ts +++ b/src/startup/cliOptions.ts @@ -32,6 +32,20 @@ export interface RootCliOptions extends CLIOptions { export interface InitialCliOptionsNormalization { deprecatedBrowserOption?: DeprecatedBrowserOption; + invalidOption?: string; +} + +const TRUE_WORDS = new Set(['true', '1', 'yes', 'on']); +const FALSE_WORDS = new Set(['false', '0', 'no', 'off']); + +/** Commander hands `--flag [bool]` values through as strings; map the usual spellings. */ +export function parseBooleanOption(value: unknown): boolean | undefined | 'invalid' { + if (value === undefined || typeof value === 'boolean') return value; + if (typeof value !== 'string') return 'invalid'; + const word = value.trim().toLowerCase(); + if (TRUE_WORDS.has(word)) return true; + if (FALSE_WORDS.has(word)) return false; + return 'invalid'; } export function normalizeInitialCliOptions( @@ -72,6 +86,13 @@ export function normalizeInitialCliOptions( if (goal === true) { options.goal = ''; } + const syncSettings = parseBooleanOption(options.syncSettings); + if (syncSettings === 'invalid') { + result.invalidOption = `--sync-settings expects true or false, got ${JSON.stringify(options.syncSettings)}`; + options.syncSettings = undefined; + } else { + options.syncSettings = syncSettings; + } if (options.systemPrompt) { options.sysPrompt = options.systemPrompt; } diff --git a/src/startup/workspaceTrustPrompt.ts b/src/startup/workspaceTrustPrompt.ts index 694be63f7..37798c973 100644 --- a/src/startup/workspaceTrustPrompt.ts +++ b/src/startup/workspaceTrustPrompt.ts @@ -30,7 +30,7 @@ function countLabel(count: number, singular: string, plural: string): string { return `${count} ${count === 1 ? singular : plural}`; } -/** For example "2 project hooks and 1 MCP server". */ +/** For example "2 project hooks and 1 MCP server" or "1 project tool file". */ export function describeWorkspaceTrustEntries(trust: WorkspaceTrustState): string { const parts: string[] = []; if (trust.hooks.length > 0) { @@ -39,7 +39,11 @@ export function describeWorkspaceTrustEntries(trust: WorkspaceTrustState): strin if (trust.mcpServers.length > 0) { parts.push(countLabel(trust.mcpServers.length, 'MCP server', 'MCP servers')); } - return parts.join(' and '); + const tools = trust.tools ?? []; + if (tools.length > 0) { + parts.push(countLabel(tools.length, 'project tool file', 'project tool files')); + } + return parts.length <= 2 ? parts.join(' and ') : `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`; } function describeServerLaunch(server: McpServerConfigEntry): string { @@ -67,6 +71,10 @@ export function formatWorkspaceTrustSummary(trust: WorkspaceTrustState): string ...formatRows(trust.mcpServers.map((server) => [server.name, describeServerLaunch(server)])), ); } + const tools = trust.tools ?? []; + if (tools.length > 0) { + lines.push('', `Project tools: ${tools.length} file(s)`, ...tools.map((tool) => ` ${tool.file}`)); + } return lines.join('\n'); } diff --git a/src/sync/CodingAgentControlPlane.ts b/src/sync/CodingAgentControlPlane.ts index df619a691..196887508 100644 --- a/src/sync/CodingAgentControlPlane.ts +++ b/src/sync/CodingAgentControlPlane.ts @@ -382,8 +382,10 @@ export function applyManagedConnectors( ): { changed: boolean; servers: McpServerConfigEntry[] } { const validated = validateConnectorSnapshot(revision, connectors); const currentServers = config.mcp?.servers || []; - const unmanaged = currentServers.filter((server) => !server.managedConnectorId && !validated.some((connector) => - connector.name === server.name && connectorTarget(connector) === connectorTarget(server), + const unmanaged = currentServers.filter((server) => server.localOnly || ( + !server.managedConnectorId && !validated.some((connector) => + connector.name === server.name && connectorTarget(connector) === connectorTarget(server), + ) )); const names = new Set(unmanaged.map(({ name }) => name)); const managed = validated.map((connector) => { @@ -450,7 +452,7 @@ export async function syncCodingAgentControlPlane( if (options.publishLocalConnectors) { let published = false; for (const server of config.mcp?.servers || []) { - if (server.managedConnectorId || (server.transport !== 'http' && server.transport !== 'stdio')) continue; + if (server.localOnly || server.managedConnectorId || (server.transport !== 'http' && server.transport !== 'stdio')) continue; if (initialState.connectors.some(({ name }) => name.toLowerCase() === server.name.toLowerCase())) continue; await client.createConnector(authToken, server, options.signal); published = true; diff --git a/src/testing/fixtures/free-search-fetch.mjs b/src/testing/fixtures/free-search-fetch.mjs new file mode 100644 index 000000000..0d31beb57 --- /dev/null +++ b/src/testing/fixtures/free-search-fetch.mjs @@ -0,0 +1,13 @@ +const realFetch = globalThis.fetch; +globalThis.fetch = async (input, options) => { + const url = input instanceof Request ? input.url : String(input); + if (url === 'https://dev.autohand.ai/api/native/web-search') { + if (options?.method !== 'GET') throw new Error('This terminal fixture only reads search availability.'); + return Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { + limit: 10, remaining: Number(process.env.AUTOHAND_TEST_SEARCH_REMAINING), resetAt: '2026-10-27T00:00:00.000Z', + } }); + } + const parsed = new URL(url); + if (['127.0.0.1', 'localhost'].includes(parsed.hostname)) return realFetch(input, options); + throw new Error('Unexpected outbound request in the Free search terminal fixture.'); +}; diff --git a/src/testing/scenarios/cloudScenario.ts b/src/testing/scenarios/cloudScenario.ts index 537c54945..81e442750 100644 --- a/src/testing/scenarios/cloudScenario.ts +++ b/src/testing/scenarios/cloudScenario.ts @@ -9,6 +9,15 @@ export function cloudViewport(session: Pick): string } export const cloudScenarioId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +export async function startCloudWithoutRepository(session: Pick): Promise { + await session.waitForText('Cloud environment'); + await session.press('down'); + await session.waitForText('▸ 2. Start a hosted conversation without a repository'); + await session.press('enter'); + await session.waitForText('Cloud agent connected'); +} + export async function seedCloudScenario(directory: string, tier = 'pro', review = false, queueSize = 0, handoff = false): Promise { const file = path.join(directory, 'cloud-fetch.mjs'); const queued = Array.from({ length: queueSize }, (_, index) => ({ id: `bbbbbbbb-bbbb-4bbb-8bbb-${String(index).padStart(12, '0')}`, state: 'queued', revision: 3, content: `Correction ${index + 1}` })); diff --git a/src/testing/scenarios/freeSearchScenario.ts b/src/testing/scenarios/freeSearchScenario.ts new file mode 100644 index 000000000..183ba702a --- /dev/null +++ b/src/testing/scenarios/freeSearchScenario.ts @@ -0,0 +1,14 @@ +import type { Session } from 'tuistory'; + +export async function inspectFreeSearchAllowance(session: Session, remaining: number): Promise { + await session.waitForText('❯', { timeout: 20_000 }); + await session.type('/search'); + await session.press('enter'); + await session.waitForText(`${remaining} of 10 free web searches left today. Resets at 00:00 UTC.`, { timeout: 15_000 }); + await session.waitForText('Resets at 00:00 UTC.'); + await session.waitForText('Select search provider:'); + await session.waitForText('Autohand'); + await session.press('escape'); + await session.waitForText('Search configuration cancelled.'); + await session.waitForText('❯'); +} diff --git a/src/testing/scenarios/queuedSteeringScenario.ts b/src/testing/scenarios/queuedSteeringScenario.ts index ee2eb7d98..517719220 100644 --- a/src/testing/scenarios/queuedSteeringScenario.ts +++ b/src/testing/scenarios/queuedSteeringScenario.ts @@ -1,4 +1,5 @@ import type { Session } from 'tuistory'; +import { waitForTerminalScreen } from '../drivers/tuistory-driver.js'; export async function startQueuedSteeringTurn(session: Session): Promise { await session.waitForText('❯', { timeout: 20_000 }); @@ -8,43 +9,37 @@ export async function startQueuedSteeringTurn(session: Session): Promise { } export async function steerPendingMessage( - session: Session, + session: Pick, options: { sequence: string; selected: boolean; fullScreen?: boolean }, ): Promise { - const row = () => session.getTerminalData().lines - .slice(-session.currentRows) - .map(line => line.spans.map(span => span.text).join('').trimStart().startsWith('❯')) - .lastIndexOf(true); - await session.waitIdle(); - const baseline = row(); - const checkAnchor = async () => { - await session.waitIdle(); - if (options.fullScreen && (baseline < 0 || row() !== baseline)) throw new Error(`Composer moved from row ${baseline} to ${row()}`); - }; + const row = (screen: string) => screen.split('\n').slice(-session.currentRows) + .map(line => line.trimStart().startsWith('❯')).lastIndexOf(true); + const initial = options.fullScreen ? await waitForTerminalScreen(session, { + timeout: 10_000, + waitFor: screen => row(screen) >= session.currentRows - 7 && row(screen) < session.currentRows, + }) : ''; + const baseline = row(initial); + const waitForState = (predicate: (screen: string) => boolean, timeout = 10_000) => waitForTerminalScreen(session, { + timeout, + waitFor: screen => predicate(screen) && (!options.fullScreen || row(screen) === baseline), + }); const messages = options.selected ? ['QUEUE_LATER: leave this for the following turn', 'STEER_NOW: mention the worktrees too'] : ['STEER_NOW: mention the worktrees too', 'QUEUE_LATER: leave this for the following turn']; for (let index = 0; index < messages.length; index++) { await session.type(messages[index]); await session.press('enter'); - await session.waitForText('Queue · ' + (index + 1) + ' pending', { timeout: 10_000 }); - await checkAnchor(); + await waitForState(screen => screen.includes('Queue · ' + (index + 1) + ' pending')); } if (options.selected) { await session.press('down'); await session.press('down'); - await session.waitForText('› 2. STEER_NOW', { timeout: 5_000 }); - await checkAnchor(); + await waitForState(screen => screen.includes('› 2. STEER_NOW'), 5_000); } else { await session.type('KEEP_THIS_DRAFT'); } session.writeRaw(options.sequence); - const screen = await session.text({ - timeout: 10_000, - waitFor: text => text.includes('Queue · 1 pending') && text.includes('QUEUE_LATER') - && (options.selected || text.includes('❯ KEEP_THIS_DRAFT')), - }); - await checkAnchor(); - return screen; + return waitForState(text => text.includes('Queue · 1 pending') && text.includes('QUEUE_LATER') + && (options.selected || text.includes('❯ KEEP_THIS_DRAFT'))); } export function pendingQueueSnapshot(screen: string): string[] { diff --git a/src/testing/scenarios/released-installer-smoke.py b/src/testing/scenarios/released-installer-smoke.py new file mode 100644 index 000000000..3fe115ab7 --- /dev/null +++ b/src/testing/scenarios/released-installer-smoke.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Exercise today's installer against an independently published release.""" +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + +parser = argparse.ArgumentParser() +parser.add_argument('--channel', choices=['stable', 'alpha'], default='stable') +parser.add_argument('--version', default='latest') +parser.add_argument('--installer', type=Path) +args = parser.parse_args() +repo = Path(__file__).resolve().parents[3] +with tempfile.TemporaryDirectory(prefix='autohand-release-install-') as folder: + root = Path(folder) + user = root / 'home' + binary_dir = root / 'bin' + user.mkdir() + binary_dir.mkdir() + # Do not inherit a developer's config, opt-outs, executable paths or aliases. + environment = {key: value for key, value in os.environ.items() + if not key.startswith('AUTOHAND_') and key not in ['HOME', 'PATH']} + environment.update(HOME=str(user), PATH=f'{binary_dir}:/usr/bin:/bin:/usr/sbin:/sbin', + AUTOHAND_INSTALL_DIR=str(binary_dir), AUTOHAND_CHANNEL=args.channel, + AUTOHAND_VERSION=args.version, AUTOHAND_INSTALL_FIRST_RUN='no', + AUTOHAND_SKIP_COMPUTER_USE_PERMISSIONS='1') + result = subprocess.run(['/bin/sh', str((args.installer or repo / 'install.sh').resolve())], env=environment, + capture_output=True, text=True, timeout=360) + print(result.stdout, end='') + print(result.stderr, end='') + assert result.returncode == 0, f'installer exit {result.returncode}' + assert 'Autohand CLI installed successfully' in result.stdout + assert 'Installation is incomplete' not in result.stdout + binary = binary_dir / 'autohand' + def probe(executable, *arguments): + return subprocess.check_output([str(executable), *arguments], env=environment, + text=True, timeout=30) + help_text = probe(binary, '--help') + supported = any(line.strip().startswith('computer ') for line in help_text.splitlines()) + if not supported: + assert 'Computer control arrives in the next release' in result.stdout + assert 'Retry with:' not in result.stdout + assert 'autohand computer doctor' not in result.stdout + if args.channel == 'alpha': + assert supported, 'alpha is expected to expose Computer control' + for alias in ['agent', 'ah', 'autohand-code']: + assert (binary_dir / alias).resolve() == binary.resolve() + assert (binary_dir / 'ahtraces').is_file(), 'trace companion installed' + version = probe(binary, '--version').strip() + traces_version = probe(binary_dir / 'ahtraces', '--version').strip() + if args.version != 'latest': + assert args.version.lstrip('v') in version, version + print(f'PASS clean HOME {args.channel}/{args.version}: CLI={version}; ahtraces={traces_version}; computer={supported}') diff --git a/src/testing/scenarios/teamTerminalStatusScenario.ts b/src/testing/scenarios/teamTerminalStatusScenario.ts index b7cc75268..646c611e9 100644 --- a/src/testing/scenarios/teamTerminalStatusScenario.ts +++ b/src/testing/scenarios/teamTerminalStatusScenario.ts @@ -26,3 +26,33 @@ export async function inspectTerminalTeamOutcomes(session: Session): Promise<{ t }); return { team, tasks }; } + +export async function recoverFailedTeam(session: Session) { + await session.press(['ctrl', 't']); + await session.waitForText('Enter inspect'); + await session.press('enter'); + const failure = await session.text({ timeout: 10_000, waitFor: text => text.includes('SECURITY_TASK_FAILED') }); + await session.press('escape'); + await session.waitForText('Enter inspect'); + await session.press('r'); + await session.waitForText('Retry Review authentication?'); + await session.press('y'); + await session.waitForText('Retrying 1 failed task'); + await session.press(['ctrl', 't']); + const retried = await session.text({ timeout: 30_000, waitFor: text => text.includes('1 succeeded') && text.includes('1 cancelled') }); + await session.press('s'); + await session.waitForText('Shut down this team'); + await session.press('n'); + await session.waitForText('Enter inspect'); + await session.press('s'); + await session.waitForText('Shut down this team'); + await session.press('y'); + const closed = await session.text({ timeout: 15_000, waitFor: text => text.includes('has been shut down') && text.includes('❯') }); + await session.type('/team create replacement'); + await session.press('enter'); + const replacement = await session.text({ timeout: 10_000, waitFor: text => text.includes('Team "replacement" created') }); + await session.type('/team shutdown'); + await session.press('enter'); + await session.waitForText('Team "replacement" has been shut down'); + return { failure, retried, closed, replacement }; +} diff --git a/src/testing/snapshots/shortcut-help-wide.txt b/src/testing/snapshots/shortcut-help-wide.txt index 2a32569b4..790487470 100644 --- a/src/testing/snapshots/shortcut-help-wide.txt +++ b/src/testing/snapshots/shortcut-help-wide.txt @@ -3,9 +3,13 @@ Keyboard shortcuts Compose Session Transcript / Commands shift + tab Change mode /whatityped Typed history @ Mention files enter Queue while working ctrl + o Expand output -$ Skills ↑ / ↓ Select queued message pgup / pgdn Scroll -! Shell command enter Steer selected message ctrl+f Find text -shift + enter New line ctrl+enter Steer queued input ctrl+home First message -alt + enter New line esc Interrupt ctrl+end / esc Back to bottom -tab Accept suggestion ctrl+c Clear input / exit ↑ / ↓ Recall prompt -enter Send prompt ? Close shortcuts +$ Skills ctrl + t Team panel pgup / pgdn Scroll +! Shell command alt + t Team panel ctrl+f Find text +shift + enter New line ctrl + g Goals panel ctrl+home First message +alt + enter New line alt + g Goals panel ctrl+end / esc Back to bottom +tab Accept suggestion ↑ / ↓ Select queued message ↑ / ↓ Recall prompt +enter Send prompt enter Steer selected message + ctrl+enter Steer queued input + esc Interrupt + ctrl+c Clear input / exit + ? Close shortcuts diff --git a/src/types.ts b/src/types.ts index e90ac60b5..be9dfb2fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -542,6 +542,10 @@ export interface PermissionRule { } export interface PermissionSettings { + /** Runtime workspace layer. Kept separate so source-rule semantics cannot shadow user denies. */ + workspaceScopes?: PermissionSettings[]; + /** Source semantics for rules added by the Claude permission importer. */ + importedClaudeRules?: import('./permissions/importedClaudeRules.js').ImportedClaudeRule[]; /** Permission mode: interactive (default), unrestricted (no prompts), restricted (deny all dangerous), external (callback) */ mode?: PermissionMode; /** Commands/tools that never require approval (e.g., "run_command:npm *") */ @@ -575,6 +579,8 @@ export interface RunConfigOverlaySnapshot { } export interface ShellSettings { + executable?: string; + dialect?: 'posix' | 'cmd' | 'powershell'; /** Environment inherited by commands Autohand runs; see docs/config-reference.md. */ env?: { inherit?: 'all' | 'essential' | 'none'; @@ -651,6 +657,8 @@ export interface McpServerConfigEntry { headers?: Record; /** Whether to auto-connect on startup (default: true) */ autoConnect?: boolean; + /** Keep this server on this device; do not publish it to the account control plane. */ + localOnly?: boolean; /** JSON-RPC framing for stdio servers (default: Content-Length with compatibility fallback) */ stdioFraming?: 'content-length' | 'newline'; /** Account-scoped connector ID assigned by the Autohand Console control plane. */ @@ -1147,9 +1155,9 @@ export interface LoadedConfig extends AutohandConfig { */ workspaceOverlay?: WorkspaceOverlaySnapshot; /** - * Runtime-only trust state for project hooks and MCP servers declared in - * `/.autohand/`. Present only when the workspace declares any. - * Never persisted. + * Runtime-only trust state for project hooks, MCP servers, and tool files + * declared in `/.autohand/`. Present only when the workspace + * declares any. Never persisted. */ workspaceTrust?: WorkspaceTrustState; /** @@ -1160,11 +1168,11 @@ export interface LoadedConfig extends AutohandConfig { runOverlay?: RunConfigOverlaySnapshot; } -/** Whether a workspace's project hooks and MCP servers may run. */ +/** Whether a workspace's project hooks, MCP servers, and tool files may run. */ export interface WorkspaceTrustState { /** Workspace whose project files declare the entries */ workspaceRoot: string; - /** Hash of the declared hooks and MCP servers; any change asks again */ + /** Hash of the declared hooks, MCP servers, and tool files; any change asks again */ fingerprint: string; /** True when the user trusted this workspace for exactly these entries */ trusted: boolean; @@ -1172,6 +1180,16 @@ export interface WorkspaceTrustState { hooks: HookDefinition[]; /** Project MCP servers after layering */ mcpServers: McpServerConfigEntry[]; + /** Project tool files under `.autohand/tools/`, sorted by name, with content hashes */ + tools: WorkspaceTrustToolEntry[]; +} + +/** A project tool file, identified by workspace-relative path and content hash. */ +export interface WorkspaceTrustToolEntry { + /** Path relative to the workspace root, e.g. `.autohand/tools/deploy.json` */ + file: string; + /** Hex sha256 of the file bytes */ + sha256: string; } /** Keys of plain settings objects that workspace overlays merge field by field. */ @@ -1916,7 +1934,7 @@ export type AgentAction = | { type: 'skill'; command: 'list' | 'info' | 'activate' | 'deactivate'; name?: string } | { type: 'sleep'; seconds: number; reason?: string } | { type: 'enter_worktree'; name?: string } - | { type: 'exit_worktree'; keep?: boolean } + | { type: 'exit_worktree'; keep?: boolean; discard_changes?: boolean } // Web Search Operations | { type: 'capture_test_evidence'; diff --git a/src/ui/ink/AgentUI.tsx b/src/ui/ink/AgentUI.tsx index db2d529de..83fb4ce39 100644 --- a/src/ui/ink/AgentUI.tsx +++ b/src/ui/ink/AgentUI.tsx @@ -54,6 +54,7 @@ import { TextBuffer } from '../textBuffer.js'; import { handleTextBufferKey, type KeyHandlerResult } from '../textBufferKeyHandler.js'; import { DEFAULT_KEYBINDINGS, + actionShortcutHint, matchesChord, normalizeChord, parseChord, @@ -868,7 +869,8 @@ export function AgentUI({ const { stdout } = useStdout(); const [, redrawFullScreen] = useState(0); const fullScreenKeys = useRef(false); - fullScreenKeys.current = Boolean(fullScreenTranscript) && !state.agentRunsPanelVisible; + const teamViewVisible = state.teamPanelVisible && Boolean(state.teamActivity?.team); + fullScreenKeys.current = Boolean(fullScreenTranscript) && !state.agentRunsPanelVisible && !teamViewVisible; const { colors } = useTheme(); const { t } = useTranslation(); const slashCommands = state.runtimeSlashCommands ?? slashCommandProps; @@ -2628,17 +2630,17 @@ export function AgentUI({ }, []); const handlePaste = useCallback((pastedText: string) => { - if (state.agentRunsPanelVisible || (isWorkingRef.current && !enableQueueInputRef.current)) { + if (state.agentRunsPanelVisible || teamViewVisible || (isWorkingRef.current && !enableQueueInputRef.current)) { return; } insertPastedText(pastedText); - }, [insertPastedText, state.agentRunsPanelVisible]); + }, [insertPastedText, state.agentRunsPanelVisible, teamViewVisible]); // Ink owns bracketed-paste framing at the stdin parser boundary. Its paste // channel buffers split protocol markers and keeps pasted bytes out of // useInput, so the composer receives the complete payload exactly once. usePaste(handlePaste); - useInput(stableHandleInput, { isActive: !state.agentRunsPanelVisible }); + useInput(stableHandleInput, { isActive: !state.agentRunsPanelVisible && !teamViewVisible }); // Memoize tool outputs to prevent unnecessary re-renders // Static items use the entry id as key and never re-render @@ -2798,6 +2800,12 @@ export function AgentUI({ onMessage={onMessageAgentRun} onCtrlC={onCtrlC} /> + ) : teamViewVisible && state.teamActivity?.team ? ( + onToggleTeamPanelRef.current?.()} + onCommand={command => onInstructionRef.current(command)} onCtrlC={onCtrlC} /> ) : ; - if (fullScreenTranscript) return state.agentRunsPanelVisible ? {bottom} : {bottom} : entry.isExpanded)?.id ?? expandedToolOutput?.id} rows={windowSize.rows ?? 24} columns={windowSize.columns ?? 80} footer={bottom} @@ -3213,6 +3221,7 @@ interface StatusSectionProps { completionStats: { elapsed: string; tokens: string; status?: TurnCompletionStatus } | null; activityItems?: ActivityItem[]; teamActivity?: TeamActivitySnapshot; + teamShortcut?: string; teamPanelVisible: boolean; goalActivity?: GoalSessionSnapshot; goalPanelVisible: boolean; @@ -3323,6 +3332,7 @@ const StatusSection = memo(function StatusSection({ completionStats, activityItems = [], teamActivity, + teamShortcut, teamPanelVisible, goalActivity, goalPanelVisible, @@ -3363,6 +3373,7 @@ const StatusSection = memo(function StatusSection({ provider={provider} model={model} teamActivity={teamActivity} + teamShortcut={teamShortcut} lineExtension={lineExtension} onSpinnerFrame={onWorkingSpinnerFrame} /> @@ -3420,6 +3431,7 @@ const StatusSection = memo(function StatusSection({ prev.completionStats?.status === next.completionStats?.status && prev.activityItems === next.activityItems && prev.teamActivity === next.teamActivity && + prev.teamShortcut === next.teamShortcut && prev.teamPanelVisible === next.teamPanelVisible && prev.goalActivity === next.goalActivity && prev.goalPanelVisible === next.goalPanelVisible && @@ -3918,6 +3930,7 @@ const FixedBottom = memo(function FixedBottom({ completionStats={completionStats} activityItems={activityItems} teamActivity={teamActivity} + teamShortcut={actionShortcutHint(keybindings, 'toggleTeamPanel', '/team view')} teamPanelVisible={teamPanelVisible} goalActivity={goalActivity} goalPanelVisible={goalPanelVisible} diff --git a/src/ui/ink/ShortcutsHelpPanel.tsx b/src/ui/ink/ShortcutsHelpPanel.tsx index 3b1ab464c..4c675ae15 100644 --- a/src/ui/ink/ShortcutsHelpPanel.tsx +++ b/src/ui/ink/ShortcutsHelpPanel.tsx @@ -39,6 +39,7 @@ export const ShortcutsHelpPanel = memo(function ShortcutsHelpPanel({ ] }, { title: 'Session', rows: [ ...bindingRows('cycleMode', 'Change mode'), ['enter', 'Queue while working'], + ...bindingRows('toggleTeamPanel', 'Team panel'), ...bindingRows('toggleGoals', 'Goals panel'), ['↑ / ↓', 'Select queued message'], ['enter', 'Steer selected message'], ['ctrl+enter', 'Steer queued input'], ['esc', 'Interrupt'], ['ctrl+c', 'Clear input / exit'], diff --git a/src/ui/ink/StatusLine.tsx b/src/ui/ink/StatusLine.tsx index 3497ad674..e0beffb05 100644 --- a/src/ui/ink/StatusLine.tsx +++ b/src/ui/ink/StatusLine.tsx @@ -10,6 +10,7 @@ import { useTheme } from '../theme/ThemeContext.js'; import { useTranslation } from '../i18n/index.js'; import type { Theme } from '../theme/Theme.js'; import type { TeamActivitySnapshot } from '../../core/teams/types.js'; +import { summarizeTaskOutcomes } from '../taskPanelModel.js'; export type LineSegmentColor = | 'text' @@ -52,6 +53,7 @@ export interface StatusLineProps { model?: string; /** Live background team state shown even while the lead is idle. */ teamActivity?: TeamActivitySnapshot; + teamShortcut?: string; /** Optional extension points for status-line text segments. */ lineExtension?: LineExtension; /** Fires with each spinner frame so the terminal tab can animate in step. */ @@ -81,13 +83,13 @@ function WorkingSpinner({ onFrame }: { onFrame?: (frame: number) => void }) { export function buildTeamActivitySegment( snapshot: TeamActivitySnapshot | undefined, + shortcut = 'ctrl+t', ): { segment?: LineSegment; busy: boolean } { - if (!snapshot?.team) return { busy: false }; - const done = snapshot.tasks.filter((task) => task.status === 'completed').length; + if (!snapshot?.team || snapshot.team.status !== 'active') return { busy: false }; + const outcome = summarizeTaskOutcomes(snapshot.tasks); const working = snapshot.team.members.filter((member) => member.status === 'working').length; const spawning = snapshot.team.members.filter((member) => member.status === 'spawning').length; - const open = snapshot.tasks.filter((task) => task.status !== 'completed').length; - const busy = snapshot.team.status === 'active' && (working > 0 || spawning > 0 || open > 0); + const busy = working > 0 || spawning > 0 || outcome.open > 0; const workerLabel = working > 0 ? `${working} working` : spawning > 0 @@ -98,8 +100,8 @@ export function buildTeamActivitySegment( busy, segment: { id: 'team-activity', - text: `${snapshot.team.name} · ${done}/${snapshot.tasks.length} done · ${workerLabel} · cmd+t team`, - color: busy ? 'warning' : 'success', + text: `${snapshot.team.name} · ${outcome.settled ? outcome.label : `${outcome.completed}/${outcome.total} done · ${workerLabel}`} · ${shortcut} team`, + color: outcome.failed > 0 ? 'error' : busy || outcome.cancelled > 0 ? 'warning' : 'success', }, }; } @@ -223,12 +225,13 @@ function StatusLineComponent({ tokens, queueCount = 0, teamActivity, + teamShortcut, lineExtension, onSpinnerFrame, }: StatusLineProps) { const { colors, theme } = useTheme(); const { t } = useTranslation(); - const teamStatus = buildTeamActivitySegment(teamActivity); + const teamStatus = buildTeamActivitySegment(teamActivity, teamShortcut); const defaultSegments = [ ...(isWorking ? buildStatusSegments(status, elapsed, tokens, queueCount, t('ui.escToCancel')) : []), ...(teamStatus.segment ? [teamStatus.segment] : []), diff --git a/src/ui/ink/TeamPanel.tsx b/src/ui/ink/TeamPanel.tsx index 18eaff59b..8b3e11c56 100644 --- a/src/ui/ink/TeamPanel.tsx +++ b/src/ui/ink/TeamPanel.tsx @@ -3,15 +3,32 @@ * Copyright 2025 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ -import React, { memo } from 'react'; -import { Box, Text } from 'ink'; +import React, { memo, useCallback, useRef, useState } from 'react'; +import { Box, Text, useInput, type Key } from 'ink'; +import wrapAnsi from 'wrap-ansi'; import type { Team, TeamTask } from '../../core/teams/types.js'; import { useTheme } from '../theme/ThemeContext.js'; import type { ColorToken } from '../theme/types.js'; +import { summarizeTaskOutcomes } from '../taskPanelModel.js'; +import { sanitizeAnnouncementText } from '../../announcements/AnnouncementContent.js'; +import { DEFAULT_KEYBINDINGS, actionShortcutHint, type ResolvedKeybindings } from '../../keybindings/profiles.js'; export interface TeamPanelProps { team: Team; tasks: TeamTask[]; + onHide?: () => void; + onCommand?: (command: string) => void; + onCtrlC?: () => void; + keybindings?: ResolvedKeybindings; + terminalRows?: number; + terminalColumns?: number; +} + +type TeamPanelMode = { kind: 'list' } | { kind: 'details'; offset: number } + | { kind: 'confirm'; command: string; question: string }; + +function clean(value: string): string { + return sanitizeAnnouncementText(value, { maxCharacters: 8192, preserveParagraphs: true }); } const StatusIcon = memo(({ status }: { status: string }) => { @@ -32,24 +49,86 @@ const StatusIcon = memo(({ status }: { status: string }) => { }); StatusIcon.displayName = 'StatusIcon'; -export const TeamPanel = memo(({ team, tasks }: TeamPanelProps) => { +export const TeamPanel = memo(({ team, tasks, onHide, onCommand, onCtrlC, + keybindings = DEFAULT_KEYBINDINGS, terminalRows = 24, terminalColumns = 80 }: TeamPanelProps) => { const { theme } = useTheme(); - const done = tasks.filter((t) => t.status === 'completed').length; + const outcome = summarizeTaskOutcomes(tasks); + const done = outcome.completed; + const [selectedId, setSelectedId] = useState(tasks[0]?.id); + const [mode, setMode] = useState({ kind: 'list' }); + const selectedIndex = Math.max(0, tasks.findIndex(task => task.id === selectedId)); + const selected = tasks[selectedIndex]; + const interactive = Boolean(onHide && onCommand); + const rows = Math.max(2, terminalRows - 9); + const hideHint = `${actionShortcutHint(keybindings, 'toggleTeamPanel', '/team hide')} hide`; + const details = selected ? clean([selected.error ?? 'No error recorded.', selected.output ?? 'No output recorded.'].join('\n\n')) : 'Task unavailable.'; + const detailLines = wrapAnsi(details, Math.max(1, terminalColumns - 2), { hard: true, trim: false }).split('\n'); + + const handleInput = useRef<(input: string, key: Key) => void>(() => {}); + handleInput.current = (input, key) => { + if (key.ctrl && input === 'c') { onCtrlC?.(); return; } + if (keybindings.matches('toggleTeamPanel', { input, ctrl: key.ctrl, shift: key.shift, meta: key.meta })) { onHide?.(); return; } + if (key.escape) { + if (mode.kind === 'list') onHide?.(); + else setMode({ kind: 'list' }); + return; + } + if (mode.kind === 'confirm') { + if (input.toLowerCase() === 'y') { + onHide?.(); + onCommand?.(mode.command); + } else if (input.toLowerCase() === 'n') setMode({ kind: 'list' }); + return; + } + if (mode.kind === 'details') { + if (key.upArrow || key.downArrow) setMode({ kind: 'details', offset: Math.max(0, + Math.min(Math.max(0, detailLines.length - rows), mode.offset + (key.downArrow ? 1 : -1))) }); + return; + } + if (key.upArrow || key.downArrow) { + setSelectedId(tasks[Math.max(0, Math.min(tasks.length - 1, selectedIndex + (key.downArrow ? 1 : -1)))]?.id); + } else if (key.return && selected) setMode({ kind: 'details', offset: 0 }); + else if (input.toLowerCase() === 'h') onHide?.(); + else if (input.toLowerCase() === 's' && team.status === 'active') { + setMode({ kind: 'confirm', command: '/team shutdown', question: 'Shut down this team and stop its workers?' }); + } else if (input.toLowerCase() === 'r' && selected?.status === 'failed' && team.status === 'active') { + setMode({ kind: 'confirm', command: `/team retry ${selected.id}`, question: `Retry ${clean(selected.subject)}?` }); + } else if (input.toLowerCase() === 'a' && outcome.failed > 0 && team.status === 'active') { + setMode({ kind: 'confirm', command: '/team retry-failed', question: `Retry ${outcome.failed} failed tasks?` }); + } + }; + useInput(useCallback((input, key) => handleInput.current(input, key), []), { isActive: interactive }); + + if (mode.kind !== 'list') return + Team: {clean(team.name)} + {mode.kind === 'confirm' ? {mode.question} y confirm · n keep team : <> + {clean(selected?.subject ?? 'Task unavailable')} · {selected?.status} + {detailLines.slice(mode.offset, mode.offset + rows).map((line, index) => {line})} + ↑/↓ scroll · Esc back + } + ; + + const taskLimit = interactive ? Math.max(1, Math.floor(rows / 2)) : tasks.length; + const taskStart = Math.max(0, selectedIndex - taskLimit + 1); + const visibleTasks = tasks.slice(taskStart, taskStart + taskLimit); return ( - Team: {team.name} - {theme.fg(team.status === 'active' ? 'success' : 'muted', `● ${team.status}`)} - {theme.fg('muted', '· Cmd+T close · Ctrl+T portable')} + Team: {clean(team.name)} + {theme.fg(team.status === 'active' ? 'success' : 'muted', `● ${team.status === 'active' ? 'workers available' : 'closed'}`)} + {theme.fg('muted', `· ${hideHint}`)} + {outcome.settled ? {theme.fg(outcome.failed ? 'error' : 'muted', outcome.label)} : null} + {interactive ? ↑/↓ select · Enter inspect · r retry selected · a retry failed · s shut down team · h hide : null} Tasks [{done}/{tasks.length} done] - {tasks.map((task) => ( + {visibleTasks.map((task) => ( + {interactive ? {task.id === selected?.id ? '›' : ' '} : null} - {task.subject} + {clean(task.subject)} {task.status === 'failed' || task.status === 'cancelled' ? ( {theme.fg(task.status === 'failed' ? 'error' : 'muted', `[${task.status}]`)} ) : null} @@ -61,7 +140,7 @@ export const TeamPanel = memo(({ team, tasks }: TeamPanelProps) => { Teammates - {team.members.map((member) => ( + {team.members.slice(0, interactive ? Math.max(1, rows - visibleTasks.length - 2) : team.members.length).map((member) => ( {member.name} diff --git a/src/ui/promptCallback.ts b/src/ui/promptCallback.ts index ba61a9154..912106b6b 100644 --- a/src/ui/promptCallback.ts +++ b/src/ui/promptCallback.ts @@ -81,7 +81,8 @@ async function sendExternalRequest(request: ExternalPromptRequest): Promise { // External callback mode if (isExternalCallbackEnabled()) { @@ -103,16 +104,18 @@ export async function confirm( } // Check for unrestricted mode (auto-confirm) - if (process.env.AUTOHAND_NON_INTERACTIVE === '1' || + if (!options.forcePrompt && (process.env.AUTOHAND_NON_INTERACTIVE === '1' || process.env.CI === '1' || - process.env.AUTOHAND_YES === '1') { + process.env.AUTOHAND_YES === '1')) { return { decision: 'allow_once' }; } + if (options.forcePrompt && !process.stdin.isTTY) return { decision: 'deny_once' }; + // Interactive mode - use Modal const commandPrefix = getCommandPrefix(context?.command); const alternativeKind = commandPrefix ? 'Command' : 'Path'; - const options: ModalOption[] = [ + const choices: ModalOption[] = [ { label: t('commands.permissions.prompt.yes'), value: 'allow_once' }, { label: t('commands.permissions.prompt.no'), value: 'deny_once' }, { label: t('commands.permissions.prompt.allowOnce'), value: 'allow_session' }, @@ -127,7 +130,7 @@ export async function confirm( const result = await showModal({ title: message, - options, + options: choices, initialIndex: 0 }); diff --git a/src/ui/shellCommand.ts b/src/ui/shellCommand.ts index ccc22b5ad..4d698d205 100644 --- a/src/ui/shellCommand.ts +++ b/src/ui/shellCommand.ts @@ -22,6 +22,7 @@ import { buildAutohandChildProcessEnv } from '../utils/childProcessEnv.js'; import { writeAutohandDebugLine } from '../utils/debugLog.js'; import { prepareCommandCoordination, spawnCoordinatedProcess, waitForProcessPublication } from '../session/peers/CommandCoordinationGate.js'; import { CommandOutputCapture } from '../utils/commandOutputCapture.js'; +import { resolveShellExecution } from '../utils/shellExecution.js'; export type { BackgroundProcessCompletion } from '../actions/command.js'; @@ -378,7 +379,7 @@ interface PtyProcess { interface NodePtyModule { spawn( file: string, - args?: string[], + args?: string[] | string, options?: { name?: string; cols?: number; @@ -545,9 +546,11 @@ export async function executeShellCommandAsync( throw new ShellCommandAbortedError(); } + const execution = resolveShellExecution(trimmedCommand); const child = await spawnCoordinatedProcess({ file: trimmedCommand, args: [], cwd: cwd ?? process.cwd() }, { - shell: true, detached: SUPPORTS_PROCESS_GROUP_SIGNALS, stdio: ['ignore', 'pipe', 'pipe'], env: buildAutohandChildProcessEnv(), - }, options.signal); + windowsVerbatimArguments: execution.windowsVerbatimArguments, + shell: false, detached: SUPPORTS_PROCESS_GROUP_SIGNALS, stdio: ['ignore', 'pipe', 'pipe'], env: buildAutohandChildProcessEnv(), + }, options.signal, execution); return new Promise((resolve, reject) => { const stdout = new CommandOutputCapture(); @@ -685,22 +688,6 @@ export function supportsPtyExecution( return (runtimeVersions as { bun?: string }).bun === undefined; } -function getPtyShellLaunch(command: string): { file: string; args: string[] } { - if (process.platform === 'win32') { - const comspec = process.env.ComSpec || 'cmd.exe'; - return { - file: comspec, - args: ['/d', '/s', '/c', command], - }; - } - - const shell = process.env.SHELL || '/bin/sh'; - return { - file: shell, - args: ['-lc', command], - }; -} - export async function executeStreamingShellCommand( command: string, cwd?: string, @@ -758,12 +745,12 @@ export async function executeStreamingShellCommand( return executeShellCommandAsync(trimmedCommand, cwd, DEFAULT_SHELL_TIMEOUT_MS, options); } - const { file, args } = getPtyShellLaunch(trimmedCommand); - const coordinated = await prepareCommandCoordination({ file, args, cwd: cwd ?? process.cwd() }, options.signal); + const { file, args, windowsVerbatimArguments } = resolveShellExecution(trimmedCommand); + const coordinated = await prepareCommandCoordination({ file: trimmedCommand, args: [], cwd: cwd ?? process.cwd() }, options.signal); let ptyProcess: PtyProcess; try { options.signal?.throwIfAborted(); - ptyProcess = nodePty.spawn(file, args, { + ptyProcess = nodePty.spawn(file, windowsVerbatimArguments ? args.join(' ') : args, { name: process.env.TERM || 'xterm-256color', cols: Math.max(20, options.columns ?? process.stdout.columns ?? 80), rows: Math.max(10, options.rows ?? process.stdout.rows ?? 24), diff --git a/src/ui/taskPanelModel.ts b/src/ui/taskPanelModel.ts index a77855ba3..7c82dfda9 100644 --- a/src/ui/taskPanelModel.ts +++ b/src/ui/taskPanelModel.ts @@ -11,6 +11,21 @@ export type TaskPanelStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; +export function summarizeTaskOutcomes(tasks: readonly { status: TaskPanelStatus }[]) { + const counts = { pending: 0, in_progress: 0, completed: 0, failed: 0, cancelled: 0 }; + for (const task of tasks) counts[task.status]++; + const open = counts.pending + counts.in_progress; + const settled = tasks.length > 0 && open === 0; + const outcome = counts.failed > 0 ? 'Finished with failures' : counts.cancelled > 0 ? 'Finished with cancellations' : 'Finished'; + const label = [ + outcome, + `${counts.completed} succeeded`, + ...(counts.failed ? [`${counts.failed} failed`] : []), + ...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []), + ].join(' · '); + return { ...counts, total: tasks.length, open, settled, label }; +} + export interface TaskPanelRow { id?: string; title: string; diff --git a/src/utils/childProcessEnv.ts b/src/utils/childProcessEnv.ts index 5f9942e3f..3fac889ad 100644 --- a/src/utils/childProcessEnv.ts +++ b/src/utils/childProcessEnv.ts @@ -30,6 +30,7 @@ export const ESSENTIAL_CHILD_ENV_KEYS: readonly string[] = [ 'TERM', 'TERM_PROGRAM', 'COLORTERM', 'SSH_AUTH_SOCK', // Windows needs these to locate system binaries and profiles. 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'ProgramData', + 'MSYSTEM', 'MSYS2_ARG_CONV_EXCL', 'MSYS_NO_PATHCONV', ]; const ALWAYS_PRESENT_PREFIX = 'AUTOHAND_'; diff --git a/src/utils/sessionWorktree.ts b/src/utils/sessionWorktree.ts index 8a702c486..4f00683c7 100644 --- a/src/utils/sessionWorktree.ts +++ b/src/utils/sessionWorktree.ts @@ -9,6 +9,7 @@ import { executeCoordinatedFile, getCommandCoordination } from '../session/peers import { existsSync } from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import { WorktreeManager } from '../actions/worktree.js'; export type SessionWorktreeOption = boolean | string | undefined; @@ -23,6 +24,7 @@ export interface SessionWorktreeInfo { worktreePath: string; branchName: string; createdBranch: boolean; + baseRef: string; } interface GitResult { @@ -97,6 +99,12 @@ export function isSessionWorktreeEnabled(worktree: SessionWorktreeOption): workt function planSessionWorktree(input: SessionWorktreeInput): { info: SessionWorktreeInfo; args: string[] } { const repoRoot = ensureGitRepo(input.cwd); + const original = runGit(repoRoot, ['rev-parse', '--symbolic-full-name', 'HEAD']); + const base = original.status === 0 && original.stdout.trim().startsWith('refs/heads/') + ? original + : runGit(repoRoot, ['rev-parse', '--verify', 'HEAD']); + if (base.status !== 0 || !base.stdout.trim()) throw new Error('Cannot determine the original worktree branch or commit.'); + const baseRef = base.stdout.trim(); const branchName = typeof input.worktree === 'string' ? sanitizeName(input.worktree) : buildAutoBranchName(input.mode); @@ -111,7 +119,7 @@ function planSessionWorktree(input: SessionWorktreeInput): { info: SessionWorktr ? ['worktree', 'add', worktreePath, branchName] : ['worktree', 'add', '-b', branchName, worktreePath]; - return { info: { repoRoot, worktreePath, branchName, createdBranch: !existingBranch }, args }; + return { info: { repoRoot, worktreePath, branchName, createdBranch: !existingBranch, baseRef }, args }; } function checkWorktreeResult(result: GitResult): void { @@ -134,3 +142,14 @@ export async function prepareCoordinatedSessionWorktree(input: SessionWorktreeIn checkWorktreeResult(await executeCoordinatedFile({ file: 'git', args, cwd: info.repoRoot })); return info; } + +export async function cleanupSessionWorktree(info: SessionWorktreeInfo): Promise { + try { + return await new WorktreeManager(info.repoRoot).remove(info.worktreePath, { + baseRef: info.baseRef, + deleteBranch: info.createdBranch, + }); + } catch (error) { + return `Kept worktree ${info.worktreePath} (branch ${info.branchName}); cleanup could not complete safely: ${error instanceof Error ? error.message : String(error)}`; + } +} diff --git a/src/utils/shellExecution.ts b/src/utils/shellExecution.ts new file mode 100644 index 000000000..338e0dc4f --- /dev/null +++ b/src/utils/shellExecution.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import type { ShellSettings } from '../types.js'; + +type ShellDialect = NonNullable; +type ShellProfileSettings = Pick; + +export interface ShellExecutionContext { + platform?: NodeJS.Platform; + environment?: NodeJS.ProcessEnv; + settings?: ShellProfileSettings; +} + +let configured: ShellProfileSettings | undefined; +let gitBashPath: { key: string; executable: string } | undefined; + +export function configureShellExecution(settings: ShellProfileSettings | undefined): void { + configured = settings?.executable ? { executable: settings.executable, dialect: settings.dialect } : undefined; + gitBashPath = undefined; +} + +function inferDialect(executable: string): ShellDialect { + const name = path.win32.basename(executable.replaceAll('/', '\\')).replace(/\.exe$/iu, '').toLowerCase(); + if (name === 'cmd') return 'cmd'; + if (name === 'pwsh' || name === 'powershell') return 'powershell'; + return 'posix'; +} + +function nativeGitBashPath(environment: NodeJS.ProcessEnv): string { + const shell = environment.SHELL || '/bin/bash'; + if (/^[a-z]:[\\/]/iu.test(shell)) return shell; + const key = `${shell}\n${environment.PATH ?? environment.Path ?? ''}`; + if (gitBashPath?.key === key) return gitBashPath.executable; + try { + const executable = execFileSync('cygpath.exe', ['-w', shell], { + env: environment, encoding: 'utf8', timeout: 1500, windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (!/^[a-z]:[\\/]/iu.test(executable)) throw new Error('Expected a Windows shell path'); + gitBashPath = { key, executable }; + return executable; + } catch { + throw new Error('Git Bash was detected but its shell path could not be resolved. Set shell.executable to the full Windows path of bash.exe.'); + } +} + +export function getShellExecutionProfile(context: ShellExecutionContext = {}): { executable: string; dialect: ShellDialect } { + const environment = context.environment ?? process.env; + const platform = context.platform ?? process.platform; + const settings = context.settings ?? configured; + const executable = settings?.executable ?? (platform === 'win32' + ? environment.MSYSTEM ? nativeGitBashPath(environment) : environment.ComSpec || environment.COMSPEC || 'cmd.exe' + : environment.SHELL || '/bin/sh'); + return { executable, dialect: settings?.dialect ?? inferDialect(executable) }; +} + +export function resolveShellExecution(command: string, context: ShellExecutionContext = {}): { file: string; args: string[]; dialect: ShellDialect; windowsVerbatimArguments?: boolean } { + const profile = getShellExecutionProfile(context); + const args = profile.dialect === 'cmd' ? ['/d', '/s', '/c', `"${command}"`] + : profile.dialect === 'powershell' ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command] + : ['-lc', command]; + return { file: profile.executable, args, dialect: profile.dialect, + ...(profile.dialect === 'cmd' ? { windowsVerbatimArguments: true } : {}) }; +} diff --git a/tests/ci/workflowCheckout.test.ts b/tests/ci/workflowCheckout.test.ts index d5e379711..34ddd99ae 100644 --- a/tests/ci/workflowCheckout.test.ts +++ b/tests/ci/workflowCheckout.test.ts @@ -91,9 +91,13 @@ describe('CI workflow checkout', () => { const workflowJobs = readWorkflowJobs().filter((job) => job.workflow === workflow); const tuistoryJobs = workflowJobs.filter((job) => job.body.includes('test:tuistory')); - expect(tuistoryJobs.map((job) => job.name), workflow).toEqual(['tuistory']); - expect(tuistoryJobs[0]?.body, workflow).not.toContain('run: bun run test:ci'); - expect(tuistoryJobs[0]?.body, workflow).not.toMatch(/run: bun run test\s*$/mu); + expect(tuistoryJobs.map((job) => job.name), workflow).toEqual( + workflow === 'ci.yml' ? ['tuistory', 'windows-tuistory'] : ['tuistory'], + ); + for (const job of tuistoryJobs) { + expect(job.body, workflow).not.toContain('run: bun run test:ci'); + expect(job.body, workflow).not.toMatch(/run: bun run test\s*$/mu); + } } }); diff --git a/tests/cloud/CloudClient.test.ts b/tests/cloud/CloudClient.test.ts index 8fd14b939..e4249778a 100644 --- a/tests/cloud/CloudClient.test.ts +++ b/tests/cloud/CloudClient.test.ts @@ -57,6 +57,14 @@ describe('cloud session identity and HTTPS boundary', () => { expect(request).toHaveBeenCalledOnce(); } }); + it.each(['free', 'lite'])('retains the server 402 status and updated upgrade message for %s', async () => { + const request = vi.fn().mockResolvedValue(Response.json({ error: { code: 'plan_required', message: 'Upgrade required' } }, { status: 402 })); + await expect(new CloudClient(identity, request).create('list files', 'auto', id)).rejects.toMatchObject({ + status: 402, + message: 'Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.', + }); + expect(request).toHaveBeenCalledOnce(); + }); it('rejects malformed responses and cancels an oversized response body', async () => { const invalid = vi.fn().mockResolvedValue(Response.json({ conversation: { id: 'foreign' } })); await expect(new CloudClient(identity, invalid).create('Task', 'auto', id)).rejects.toThrow(); diff --git a/tests/cloud/launchCloud.test.ts b/tests/cloud/launchCloud.test.ts index 6106b93ea..49cd2e6db 100644 --- a/tests/cloud/launchCloud.test.ts +++ b/tests/cloud/launchCloud.test.ts @@ -7,11 +7,11 @@ import { SessionTransferClient } from '../../src/session/transfer/transfer-clien const id = '11111111-1111-4111-8111-111111111111', workspaceId = '22222222-2222-4222-8222-222222222222'; const conversation = { id, title: 'Repair parser', model: 'moa', updated_at: '2026-10-12' }; const config: LoadedConfig = { configPath: '/fixture/config.json', auth: { token: 'cloud-fixture-token-1234567890', user: { id: 'ada', email: 'ada@autohand.test', name: 'Ada' } }, api: { accountId: 'personal_fixture' } }; -function fixture(tier = 'pro', workspaceStatus = 'ready') { +function fixture(tier = 'pro', workspaceStatus = 'ready', paymentAccess: unknown = null) { const request = vi.fn(async (url, init) => { const path = new URL(String(url)).pathname; if (path.endsWith('/session')) return Response.json({ session: { user: { id: 'ada' }, account: { account: { id: 'personal_fixture' } } } }); - if (path.endsWith('/account')) return Response.json({ tier, paymentAccess: null }); + if (path.endsWith('/account')) return Response.json({ tier, paymentAccess }); if (path.endsWith('/models')) return Response.json({ models: [{ id: 'moa', available: true }, { id: 'auto', available: true }] }); if (path.endsWith('/sandbox-providers')) return Response.json({ providers: [{ id: 'cloudflare', available: true, name: 'Cloud' }] }); if (path.endsWith('/workspaces')) return init?.method === 'POST' @@ -59,13 +59,27 @@ describe('cloud launch orchestration', () => { expect(dependencies.view).toHaveBeenCalledOnce(); }); it('checks supported plans before provisioning or uploading local context', async () => { - for (const plan of ['free', 'lite', 'team', 'enterprise', 'unknown']) { + for (const plan of ['free', 'lite', 'unknown']) { const dependencies = fixture(plan); await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, prompt: 'Repair parser' }, dependencies)).rejects.toThrow('Pro'); expect(dependencies.request.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true); expect(dependencies.view).not.toHaveBeenCalled(); } }); + it.each(['pro', 'pro_plus', 'max', 'team', 'enterprise'])('starts a cloud session for %s without changing the selected account', async tier => { + const dependencies = fixture(tier); + await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, prompt: 'list files' }, dependencies)).resolves.toMatchObject({ kind: 'detached', conversationId: id, accountId: 'personal_fixture' }); + expect(dependencies.view).toHaveBeenCalledOnce(); + const send = dependencies.request.mock.calls.find(([url]) => new URL(String(url)).pathname.endsWith('/messages')); + expect(JSON.parse(String(send?.[1]?.body)).content).toBe('list files'); + expect(new Headers(send?.[1]?.headers).get('X-Autohand-Account-Id')).toBe('personal_fixture'); + }); + it.each(['pro', 'team', 'enterprise'])('keeps billing-paused %s accounts from provisioning or sending work', async tier => { + const dependencies = fixture(tier, 'ready', { reason: 'past_due' }); + await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, prompt: 'list files' }, dependencies)).rejects.toThrow("Cloud CLI sessions are paused while there's a billing issue on this account. Check billing in Console."); + expect(dependencies.request.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true); + expect(dependencies.view).not.toHaveBeenCalled(); + }); it('refuses a resume link from another account before making a network request', async () => { const dependencies = fixture(); await expect(launchCloud({ config, workspaceRoot: '/fixture/parser' }, { cloud: true, resume: `https://dev.autohand.ai/c/${id}?account=foreign` }, dependencies)).rejects.toThrow('account'); diff --git a/tests/commands/model.spec.ts b/tests/commands/model.spec.ts index 07d864c83..e12dd7320 100644 --- a/tests/commands/model.spec.ts +++ b/tests/commands/model.spec.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { OPENROUTER_ATTRIBUTION_HEADERS } from "../../src/providers/OpenRouterClient.js"; // Mock fetch globally const mockFetch = vi.fn(); @@ -121,8 +122,7 @@ describe("API Key Validation", () => { headers: { Authorization: "Bearer sk-or-valid-key", "Content-Type": "application/json", - "HTTP-Referer": "https://autohand.dev", - "X-Title": "Autohand CLI", + ...OPENROUTER_ATTRIBUTION_HEADERS, }, }); @@ -130,8 +130,8 @@ describe("API Key Validation", () => { "https://openrouter.ai/api/v1/models", expect.objectContaining({ headers: expect.objectContaining({ - "HTTP-Referer": "https://autohand.dev", - "X-Title": "Autohand CLI", + "HTTP-Referer": "https://autohand.ai", + "X-Title": "Autohand Code", }), }), ); diff --git a/tests/commands/searchUsage.test.ts b/tests/commands/searchUsage.test.ts new file mode 100644 index 000000000..ef57760ef --- /dev/null +++ b/tests/commands/searchUsage.test.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { search } from '../../src/commands/search.js'; +import { showModal } from '../../src/ui/ink/components/Modal.js'; + +vi.mock('../../src/ui/ink/components/Modal.js', () => ({ showModal: vi.fn().mockResolvedValue(null), showPassword: vi.fn() })); +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +describe('/search Free daily usage', () => { + it.each([7, 0])('shows %s remaining and retains Autohand as a selectable provider', async remaining => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining, resetAt: '2026-10-27T00:00:00.000Z' } }))); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + await search({ config: { configPath: '/fixture', auth: { token: 'ahc_verified_free' } } }); + const usage = `${remaining} of 10 free web searches left today. Resets at 00:00 UTC.`; + expect(log.mock.calls.some(args => String(args[0]).includes(usage))).toBe(true); + expect(showModal).toHaveBeenLastCalledWith(expect.objectContaining({ options: expect.arrayContaining([expect.objectContaining({ value: 'autohand', description: usage })]) })); + }); +}); diff --git a/tests/commands/team.test.ts b/tests/commands/team.test.ts index bac14c447..216f7cd6e 100644 --- a/tests/commands/team.test.ts +++ b/tests/commands/team.test.ts @@ -37,6 +37,35 @@ function createMockTeamManager(hasTeam = true) { } describe('/team command', () => { + it('offers explicit recovery and retries only failed tasks', async () => { + const manager = new TeamManager({ leadSessionId: 'lead', workspacePath: process.cwd() }); + manager.createTeam('recovery'); + const success = manager.tasks.createTask({ subject: 'Successful work', description: '' }); + const failure = manager.tasks.createTask({ subject: 'Broken review', description: '' }); + const cancelled = manager.tasks.createTask({ subject: 'Cancelled work', description: '' }); + manager.tasks.updateTask(success.id, { status: 'completed' }); + manager.tasks.updateTask(failure.id, { status: 'failed', error: 'Provider unavailable' }); + manager.stopTask(cancelled.id); + + expect(await team({ teamManager: manager }, ['errors'])).toContain('Provider unavailable'); + expect(await team({ teamManager: manager }, ['retry', success.id])).toContain('Only failed tasks'); + expect(await team({ teamManager: manager }, ['retry-failed'])).toContain('Retrying 1 failed task'); + expect(manager.tasks.getTask(failure.id)?.status).toBe('pending'); + expect(manager.tasks.getTask(success.id)?.status).toBe('completed'); + expect(manager.tasks.getTask(cancelled.id)?.status).toBe('cancelled'); + }); + + it('hides the panel after successful shutdown without replacing failed outcomes', async () => { + const manager = new TeamManager({ leadSessionId: 'lead', workspacePath: process.cwd() }); + manager.createTeam('recovery'); + const failure = manager.tasks.createTask({ subject: 'Broken review', description: '' }); + manager.tasks.updateTask(failure.id, { status: 'failed', error: 'Provider unavailable' }); + const onToggleTeamView = vi.fn(); + await team({ teamManager: manager, onToggleTeamView }, ['shutdown']); + expect(onToggleTeamView).toHaveBeenCalledWith(false); + expect(manager.tasks.getTask(failure.id)?.status).toBe('failed'); + expect(await team({ teamManager: manager }, ['retry-failed'])).toContain('closed'); + }); it('opens the live team view without requiring a status prompt', async () => { const onToggleTeamView = vi.fn(); const result = await team({ diff --git a/tests/computerInstallIntegration.test.ts b/tests/computerInstallIntegration.test.ts index a1cd78247..0f8674e44 100644 --- a/tests/computerInstallIntegration.test.ts +++ b/tests/computerInstallIntegration.test.ts @@ -20,6 +20,17 @@ afterEach(async () => { }); describe('computer control installation integration', () => { + it('keeps npm installation successful when optional computer setup fails', async () => { + const packageRoot = await mkdtemp(path.join(tmpdir(), 'autohand-computer-postinstall-')); + temporaryDirectories.push(packageRoot); + await mkdir(path.join(packageRoot, 'dist')); + await writeFile(path.join(packageRoot, 'dist/index.js'), ''); + const warn = vi.fn(); + await expect(installComputerUsePostinstall({ packageRoot, warn, + runCli: () => { throw new Error('fixture setup failure'); }, + })).resolves.toEqual({ status: 'failed-optional' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('fixture setup failure')); + }); async function runComputerInstall(binaryLines: string[]) { const script = await readFile('install.sh', 'utf8'); const functionSource = script.slice(script.indexOf('computer_control_supported() {'), script.indexOf('\nmain() {')); @@ -113,6 +124,7 @@ describe('computer control installation integration', () => { const distDirectory = path.join(packageRoot, 'dist'); await mkdir(distDirectory); await writeFile(path.join(distDirectory, 'index.js'), [ + "if (process.argv[2] === '--help') { console.log('Commands:\\n computer Computer control'); process.exit(0); }", "const expected = ['computer', 'install', '--non-interactive', '--postinstall', '--bin-dir', 'vendor'];", 'if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expected)) process.exit(2);', ].join('\n')); @@ -122,6 +134,17 @@ describe('computer control installation integration', () => { }); }); + it('does not invoke computer install when packaged help lacks the command', async () => { + const packageRoot = await mkdtemp(path.join(tmpdir(), 'autohand-computer-postinstall-')); + temporaryDirectories.push(packageRoot); + await mkdir(path.join(packageRoot, 'dist')); + await writeFile(path.join(packageRoot, 'dist/index.js'), [ + "if (process.argv[2] === '--help') { console.log('Commands:\\n login Sign in'); process.exit(0); }", + "throw new Error('unsupported computer install must not run');", + ].join('\n')); + await expect(installComputerUsePostinstall({ packageRoot })).resolves.toEqual({ status: 'skipped-unsupported' }); + }); + it('rejects a published package that is missing its built CLI', async () => { const packageRoot = await mkdtemp(path.join(tmpdir(), 'autohand-computer-postinstall-')); temporaryDirectories.push(packageRoot); diff --git a/tests/config/shellSettings.test.ts b/tests/config/shellSettings.test.ts index c8c2968da..c5916bda2 100644 --- a/tests/config/shellSettings.test.ts +++ b/tests/config/shellSettings.test.ts @@ -20,6 +20,8 @@ describe('shell.env config validation', () => { vi.restoreAllMocks(); const { configureChildProcessEnvPolicy } = await import('../../src/utils/childProcessEnv.js'); configureChildProcessEnvPolicy(undefined); + const { configureShellExecution } = await import('../../src/utils/shellExecution.js'); + configureShellExecution(undefined); await fse.remove(testDir); }); @@ -37,6 +39,10 @@ describe('shell.env config validation', () => { }); it.each([ + [{ executable: 42 }, 'shell.executable must be a non-empty string'], + [{ executable: '' }, 'shell.executable must be a non-empty string'], + [{ executable: 'bash', dialect: 'unknown' }, 'shell.dialect must be'], + [{ dialect: 'powershell' }, 'shell.dialect requires shell.executable'], [{ env: { inherit: 'some' } }, 'shell.env.inherit must be "all", "essential", or "none"'], [{ env: { exclude: 'PATH' } }, 'shell.env.exclude must be a list of variable names or globs'], [{ env: { set: { CI: 1 } } }, 'shell.env.set must map variable names to strings'], diff --git a/tests/config/workspaceTrust.config.test.ts b/tests/config/workspaceTrust.config.test.ts new file mode 100644 index 000000000..9b19cfc7a --- /dev/null +++ b/tests/config/workspaceTrust.config.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../../src/config.js'; +import { trustWorkspace } from '../../src/permissions/workspaceTrust.js'; + +const SCRATCH_DIR = os.tmpdir(); + +let root: string; +let configPath: string; +let storePath: string; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'trust-cfg-')); + configPath = path.join(root, 'home', 'config.json'); + storePath = path.join(root, 'home', 'trusted-workspaces.json'); + await fs.outputJson(configPath, { provider: 'anthropic', anthropic: { apiKey: 'k', model: 'm' } }); +}); + +afterEach(async () => { + await fs.remove(root); +}); + +const load = () => loadConfig(configPath, root, { createIfMissing: false, initializeTheme: false, workspaceTrustStorePath: storePath }); + +describe('workspace trust for tools-only projects', () => { + it('creates an untrusted workspaceTrust state when only .autohand/tools exists', async () => { + await fs.outputFile(path.join(root, '.autohand', 'tools', 'deploy.json'), '{"name":"deploy","description":"d","handler":"echo d"}'); + + const config = await load(); + + expect(config.workspaceTrust?.trusted).toBe(false); + expect(config.workspaceTrust?.hooks).toEqual([]); + expect(config.workspaceTrust?.mcpServers).toEqual([]); + expect(config.workspaceTrust?.tools.map((tool) => tool.file)).toEqual(['.autohand/tools/deploy.json']); + }); + + it('leaves workspaceTrust undefined when the workspace declares nothing executable', async () => { + const config = await load(); + + expect(config.workspaceTrust).toBeUndefined(); + }); + + it('asks again after a trusted tool file changes', async () => { + const toolFile = path.join(root, '.autohand', 'tools', 'deploy.json'); + await fs.outputFile(toolFile, '{"name":"deploy","description":"d","handler":"echo d"}'); + const untrusted = await load(); + await trustWorkspace(root, untrusted.workspaceTrust!.fingerprint, storePath); + expect((await load()).workspaceTrust?.trusted).toBe(true); + + await fs.writeFile(toolFile, '{"name":"deploy","description":"d","handler":"echo changed"}'); + + expect((await load()).workspaceTrust?.trusted).toBe(false); + }); +}); diff --git a/tests/core/agent.worktreeTools.spec.ts b/tests/core/agent.worktreeTools.spec.ts index 7df5b0e55..433e5cfdc 100644 --- a/tests/core/agent.worktreeTools.spec.ts +++ b/tests/core/agent.worktreeTools.spec.ts @@ -246,7 +246,8 @@ describe('AutohandAgent worktree tools', () => { const result = await agent.exitSessionWorktree(); expect(mockWorktreeRemove).toHaveBeenCalledWith('/repo-feature', { - force: true, + baseRef: undefined, + discardChanges: false, deleteBranch: true, }); expect(agent.runtime.workspaceRoot).toBe('/repo'); diff --git a/tests/core/agent/AgentPlanReview.test.ts b/tests/core/agent/AgentPlanReview.test.ts index dd2c8e8f8..1669d083c 100644 --- a/tests/core/agent/AgentPlanReview.test.ts +++ b/tests/core/agent/AgentPlanReview.test.ts @@ -13,6 +13,7 @@ const opener = vi.hoisted(() => ({ openPlanFile: vi.fn(async () => 'code') })); vi.mock('../../../src/ui/planAcceptModal.js', () => ({ showPlanAcceptModal: modal.showPlanAcceptModal })); vi.mock('../../../src/modes/planMode/openPlanFile.js', () => ({ openPlanFile: opener.openPlanFile })); +import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; import { getPlanModeManager } from '../../../src/commands/plan.js'; import { handleAgentExitPlanMode, @@ -45,6 +46,7 @@ function createHost() { const notes: string[] = []; const host = { runtime: { options: {}, workspaceRoot: '/repo' }, + permissionManager: new PermissionManager({ mode: 'unrestricted' }), conversation: { addSystemNote: (note: string) => { notes.push(note); } }, withModalPause: (callback: () => Promise) => callback(), resetConversationContext: vi.fn(async () => {}), @@ -134,6 +136,15 @@ describe('plan presentation and review', () => { expect(opener.openPlanFile).toHaveBeenCalledExactlyOnceWith(expectedPath); }); + it.each(['auto_accept', 'clear_context_auto_accept', 'manual_approve'])('applies %s to the actual permission manager', async option => { + modal.showPlanAcceptModal.mockResolvedValue({ type: 'option', optionId: option }); + vi.spyOn(PlanFileStorage.prototype, 'loadPlan').mockResolvedValue(plan()); + const { host } = createHost(); + await handleAgentExitPlanMode(host); + expect(host.permissionManager.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(option !== 'manual_approve'); + expect(host.permissionManager.checkPermission({ tool: 'run_command', command: 'npm test' }).allowed).toBe(false); + }); + it('accepts an untouched plan and lists its steps for execution', async () => { modal.showPlanAcceptModal.mockResolvedValue({ type: 'option', optionId: 'manual_approve' }); vi.spyOn(PlanFileStorage.prototype, 'loadPlan').mockResolvedValue(plan()); diff --git a/tests/core/agent/AutohandAIModelTierPolicy.test.ts b/tests/core/agent/AutohandAIModelTierPolicy.test.ts index a308ebe78..f9911106d 100644 --- a/tests/core/agent/AutohandAIModelTierPolicy.test.ts +++ b/tests/core/agent/AutohandAIModelTierPolicy.test.ts @@ -207,7 +207,7 @@ describe('resolveAutohandAIModelForTier', () => { expect(resolveAutohandAIModelForTier({ provider: 'openrouter', plan: undefined, model: 'anthropic/claude-5-sonnet', tier: 'pro' })).toBe('anthropic/claude-5-sonnet'); }); - it('rejects Weka with its API and Console route instead of treating it as an unknown chat model', async () => { + it('rejects Weka with its API, Agent SDK and Console route instead of treating it as an unknown chat model', async () => { const { resolveAutohandAIModelForTier } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); expect(() => resolveAutohandAIModelForTier({ @@ -216,7 +216,7 @@ describe('resolveAutohandAIModelForTier', () => { model: 'weka', tier: 'pro', })).toThrow( - 'Weka is available through the Autohand API and Console Playground. CLI execution is not supported yet.', + 'Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.', ); }); }); diff --git a/tests/core/agent/InstructionRunner.command-mode.test.ts b/tests/core/agent/InstructionRunner.command-mode.test.ts index 28ff89a96..a92824c5c 100644 --- a/tests/core/agent/InstructionRunner.command-mode.test.ts +++ b/tests/core/agent/InstructionRunner.command-mode.test.ts @@ -18,6 +18,8 @@ import { import { HookManager } from '../../../src/core/HookManager.js'; import { ComputerUseLifecycle } from '../../../src/computer/ComputerUseLifecycle.js'; import { ApiError, classifyApiError } from '../../../src/providers/errors.js'; +import { getPlanModeManager } from '../../../src/commands/plan.js'; +import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; import { ProviderNotConfiguredError } from '../../../src/providers/ProviderFactory.js'; function overrideStreamTTY( @@ -1159,3 +1161,26 @@ it('skips automatic specialists for an explicitly scoped native instruction', as expect(await new InstructionRunner(host).run('Inspect again')).toBe(true); expect(host.prepareSpecialists).toHaveBeenCalledTimes(1); }); + + +describe('accepted plan execution cleanup', () => { + it.each(['completed', 'failed', 'cancelled'])('restores normal permissions and iteration mode after %s execution', async outcome => { + const host = createHost(); + host.permissionManager = new PermissionManager({ mode: 'interactive' }); + host.runReactLoop = vi.fn(async controller => { + const manager = getPlanModeManager(); + manager.enable(); + manager.setPlan({ id: 'accepted', steps: [], rawText: 'Plan', createdAt: 1 }); + manager.acceptPlan('auto_accept'); + host.permissionManager!.setPlanEditApproval('auto'); + if (outcome === 'failed') throw new Error('Execution failed'); + if (outcome === 'cancelled') controller.abort(); + return { status: 'completed' }; + }); + await new InstructionRunner(host).run('execute the accepted plan'); + expect(getPlanModeManager().isEnabled()).toBe(false); + expect(getPlanModeManager().getPhase()).toBe('completed'); + expect(host.permissionManager.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + expect(getPlanModeManager().getPromptIndicator()).toBe(''); + }); +}); diff --git a/tests/core/agent/SystemPromptBuilder.test.ts b/tests/core/agent/SystemPromptBuilder.test.ts index 5a4558a75..37afcaab2 100644 --- a/tests/core/agent/SystemPromptBuilder.test.ts +++ b/tests/core/agent/SystemPromptBuilder.test.ts @@ -35,6 +35,14 @@ describe('SystemPromptBuilder', () => { }); } + it('tells the model which shell executable and dialect command tools use', async () => { + const prompt = await createBuilder({ runtime: { options: {}, workspaceRoot: process.cwd(), config: { + shell: { executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }, + } } }).build(); + expect(prompt).toContain('Command shell: "C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe"'); + expect(prompt).toContain('Command dialect: powershell'); + }); + it('includes the tool-choice rubric and compact tool catalog without runtime schemas', async () => { const builder = createBuilder(); diff --git a/tests/core/agent/TeamActivityBridge.test.ts b/tests/core/agent/TeamActivityBridge.test.ts index 0315fbb0c..551441861 100644 --- a/tests/core/agent/TeamActivityBridge.test.ts +++ b/tests/core/agent/TeamActivityBridge.test.ts @@ -108,6 +108,6 @@ describe('TeamActivityBridge', () => { manager.tasks.updateTask(tasks[2]!.id, { status: 'cancelled' }); manager.tasks.setTaskOutput(tasks[2]!.id, 'Stopped by lead'); - expect(notifyUser).toHaveBeenCalledExactlyOnceWith('Team "review" finished: 1/3 completed, 1 failed, 1 cancelled.'); + expect(notifyUser).toHaveBeenCalledExactlyOnceWith('Team "review" finished: 1/3 completed, 1 failed, 1 cancelled. Inspect with /team view; stop workers with /team shutdown.'); }); }); diff --git a/tests/core/context.spec.ts b/tests/core/context.spec.ts index eb98e2ee8..96c11d80b 100644 --- a/tests/core/context.spec.ts +++ b/tests/core/context.spec.ts @@ -87,6 +87,19 @@ function fillConversation(cm: ConversationManager, count: number, contentLength describe('context/tokenizer', () => { describe('getContextWindow', () => { + it.each(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-astra', 'gpt-6.1-sol', 'openai/gpt-6-astra'])('uses the 272k context window for %s', (model) => { + expect(getContextWindow(model)).toBe(272_000); + }); + + it('keeps the environment override above the GPT-6 inference and explicit metadata', () => { + vi.stubEnv(CONTEXT_ENV_VARS.CONTEXT_WINDOW, '123456'); + try { + expect(getContextWindow('openai/gpt-6-astra', 272_000)).toBe(123456); + } finally { + vi.unstubAllEnvs(); + } + }); + it('returns known model context windows', () => { expect(getContextWindow('anthropic/claude-4-sonnet')).toBe(200_000); expect(getContextWindow('anthropic/claude-sonnet-5')).toBe(1_000_000); diff --git a/tests/core/toolsRegistry.trust.test.ts b/tests/core/toolsRegistry.trust.test.ts new file mode 100644 index 000000000..fdacde440 --- /dev/null +++ b/tests/core/toolsRegistry.trust.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { createToolsRegistry } from '../../src/core/toolsRegistry.js'; +import { normalizeMetaToolDefinition } from '../../src/core/metaTools/schema.js'; + +const definition = (name: string, scope: 'project' | 'user') => ({ + name, + description: `${scope} ${name}`, + parameters: { type: 'object', properties: {} }, + handler: `echo ${scope}`, +}); + +const SCRATCH_DIR = os.tmpdir(); + +let root: string; +let userDir: string; + +beforeEach(async () => { + await fs.ensureDir(SCRATCH_DIR); + root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'tools-trust-')); + userDir = path.join(root, 'user-tools'); + await fs.ensureDir(path.join(root, 'workspace', '.autohand', 'tools')); + await fs.ensureDir(userDir); +}); + +afterEach(async () => { + await fs.remove(root); +}); + +describe('project meta-tools and workspace trust', () => { + it('creates an explicitly requested project tool without loading other untrusted project tools', async () => { + const workspace = path.join(root, 'workspace'); + await fs.writeJson(path.join(workspace, '.autohand', 'tools', 'untrusted.json'), definition('untrusted', 'project')); + const registry = createToolsRegistry(workspace, userDir); + await registry.initialize(); + const created = normalizeMetaToolDefinition({ ...definition('requested', 'project'), scope: 'project' }); + if (!created) throw new Error('Invalid fixture'); + await registry.saveMetaTool(created); + expect(registry.getMetaTool('requested')).toMatchObject({ scope: 'project' }); + expect(registry.getMetaTool('untrusted')).toBeUndefined(); + expect(registry.getRegistryEntries({ includeDisabled: true }).map(tool => tool.name)).toContain('requested'); + await registry.initialize(); + expect(registry.getMetaTool('requested')).toBeUndefined(); + expect(registry.getMetaTool('untrusted')).toBeUndefined(); + }); + it('loads no project meta-tools from an untrusted workspace', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'deploy.json'), definition('deploy', 'project')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: false }); + await registry.initialize(); + await registry.listTools([]); + expect(registry.getMetaTool('deploy')).toBeUndefined(); + expect(registry.getDiagnostics().map((d) => d.reason)).toContain('project tools require a trusted workspace'); + }); + + it('loads project meta-tools from a trusted workspace', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'deploy.json'), definition('deploy', 'project')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: true }); + await registry.initialize(); + await registry.listTools([]); + expect(registry.getMetaTool('deploy')?.scope).toBe('project'); + }); + + it('lets a user tool win over a project tool of the same name', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'lint.json'), definition('lint', 'project')); + await fs.writeJson(path.join(userDir, 'lint.json'), definition('lint', 'user')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: true }); + await registry.initialize(); + await registry.listTools([]); + expect(registry.getMetaTool('lint')?.scope).toBe('user'); + expect(registry.getDiagnostics().map((d) => d.reason)).toContain('project tool shadows user tool "lint"'); + }); + + it('never lets a project tool take a built-in tool name', async () => { + await fs.writeJson(path.join(root, 'workspace', '.autohand', 'tools', 'run_command.json'), definition('run_command', 'project')); + const registry = createToolsRegistry(path.join(root, 'workspace'), userDir, { projectToolsTrusted: true }); + registry.reserveBuiltInNames(['run_command']); + await registry.initialize(); + await registry.listTools([{ name: 'run_command', description: 'built in', parameters: { type: 'object', properties: {} } } as never]); + expect(registry.getMetaTool('run_command')).toBeUndefined(); + expect(registry.getDiagnostics().map((d) => d.reason)).toContain('project tool shadows built-in tool "run_command"'); + }); +}); diff --git a/tests/feedback/environment.test.ts b/tests/feedback/environment.test.ts index cc0982376..a8be6f7f2 100644 --- a/tests/feedback/environment.test.ts +++ b/tests/feedback/environment.test.ts @@ -4,10 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ import os from 'node:os'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { collectReportEnvironment } from '../../src/feedback/environment.js'; +import { configureShellExecution } from '../../src/utils/shellExecution.js'; + +afterEach(() => configureShellExecution(undefined)); describe('collectReportEnvironment', () => { + it('reports the selected execution shell rather than the inherited parent shell', () => { + configureShellExecution({ executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }); + expect(collectReportEnvironment({ env: { SHELL: '/usr/bin/bash' } })).toMatchObject({ + shell: 'pwsh.exe', shellDialect: 'powershell', parentShell: 'bash', + }); + }); it('describes the operating system and JavaScript runtime', () => { const environment = collectReportEnvironment({ env: {} }); @@ -63,7 +72,7 @@ describe('collectReportEnvironment', () => { expect(Object.keys(environment).sort()).toEqual( expect.arrayContaining(['os', 'runtime']), ); - for (const key of ['shell', 'terminal', 'term', 'multiplexer', 'locale', 'ssh', 'ci', 'provider', 'model', 'mode']) { + for (const key of ['terminal', 'term', 'multiplexer', 'locale', 'ssh', 'ci', 'provider', 'model', 'mode']) { expect(environment).not.toHaveProperty(key); } for (const value of Object.values(environment)) { diff --git a/tests/github/modelCatalogDistributionWorkflow.test.ts b/tests/github/modelCatalogDistributionWorkflow.test.ts index f7754f331..2d70f7b1e 100644 --- a/tests/github/modelCatalogDistributionWorkflow.test.ts +++ b/tests/github/modelCatalogDistributionWorkflow.test.ts @@ -60,15 +60,8 @@ describe("model catalog distribution automation", () => { "muse-wonder-1.0": expect.objectContaining({ provider: "autohandai", contextWindow: 262_144, maxTokens: 16_000 }), "qwen3.8-max": expect.objectContaining({ provider: "autohandai", contextWindow: 262_144, maxTokens: 16_000 }), "gemini-4-argon": expect.objectContaining({ provider: "autohandai", contextWindow: 1_048_576, maxTokens: 16_000 }), - weka: expect.objectContaining({ - api: "autohand-decisions", - baseUrl: "https://api.autohand.ai/v1", - cliSupported: false, - contextWindow: 32_000, - endpoint: "/decisions", - provider: "autohandai", - }), }); + expect(catalog.autohandai).not.toHaveProperty("weka"); expect(catalog.anthropic["claude-sonnet-5"]).toEqual(expect.objectContaining({ api: "anthropic-messages", baseUrl: "https://api.anthropic.com", diff --git a/tests/import/ClaudeImporter.test.ts b/tests/import/ClaudeImporter.test.ts index 8d9acec8c..ac7c310d4 100644 --- a/tests/import/ClaudeImporter.test.ts +++ b/tests/import/ClaudeImporter.test.ts @@ -6,8 +6,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; const atomicFileMocks = vi.hoisted(() => ({ + atomicWriteFile: vi.fn().mockResolvedValue(undefined), atomicWriteJson: vi.fn().mockResolvedValue(undefined), withFileLock: vi.fn( (_lockPath: string, operation: () => Promise) => operation(), @@ -16,6 +18,16 @@ const atomicFileMocks = vi.hoisted(() => ({ vi.mock('../../src/utils/atomicFile.js', () => atomicFileMocks); +vi.mock('node:child_process', async (importOriginal) => ({ + ...await importOriginal(), + execFileSync: vi.fn((file: string, args: string[]) => { + if (file !== 'git') throw new Error(`Unexpected command: ${file}`); + if (args.includes('--show-toplevel')) return '/workspace'; + if (args.includes('info/exclude')) return '/workspace/.git/info/exclude'; + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }), +})); + // Mock fs-extra before importing anything that uses it vi.mock('fs-extra', () => ({ default: { @@ -28,6 +40,9 @@ vi.mock('fs-extra', () => ({ writeFile: vi.fn().mockResolvedValue(undefined), copy: vi.fn().mockResolvedValue(undefined), stat: vi.fn(), + realpath: vi.fn(async (file: string) => file), + lstat: vi.fn(async () => ({ isSymbolicLink: () => false })), + chmod: vi.fn().mockResolvedValue(undefined), }, })); @@ -78,6 +93,150 @@ describe('ClaudeImporter', () => { // scan() // --------------------------------------------------------------- describe('scan()', () => { + it.each(['global config', 'project config'])('detects Claude when only the MCP %s exists', async (scope) => { + const root = '/workspace'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); + const onlyMcpConfig = scope === 'global config' ? path.join(HOME, '.claude.json') : path.join(root, '.mcp.json'); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => String(p) === onlyMcpConfig); + + await expect(importer.detect()).resolves.toBe(true); + }); + + it('reports MCP servers from Claude configuration', async () => { + const root = '/workspace'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => [CLAUDE_HOME, path.join(HOME, '.claude.json')].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return { mcpServers: { user: { command: 'run' } } } as never; + if (String(p) === path.join(root, '.mcp.json')) return { mcpServers: { project: { command: 'local' } } } as never; + throw new Error('missing'); + }); + const result = await importer.scan(); + expect(result.available.get('mcp')?.count).toBe(2); + }); + + it('ignores malformed MCP server entries when scanning and importing', async () => { + const root = '/workspace'; + const userConfig = '/autohand/config.json'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root, configPath: userConfig }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => String(p) === path.join(HOME, '.claude.json')); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return { + mcpServers: { + valid: { command: 'run' }, + missingCommand: { type: 'stdio' }, + missingUrl: { type: 'http' }, + unsupported: { type: 'websocket', url: 'https://example.test' }, + scalar: 'invalid', + '': { command: 'blank-name' }, + }, + } as never; + if (String(p) === userConfig) return {} as never; + throw new Error('missing'); + }); + + expect((await importer.scan()).available.get('mcp')?.count).toBe(1); + const result = await importer.import(['mcp']); + + expect(result.imported.get('mcp')).toEqual({ success: 1, failed: 0, skipped: 0 }); + expect(JSON.parse(atomicFileMocks.atomicWriteFile.mock.calls[0]?.[1])).toMatchObject({ + mcp: { servers: [{ name: 'valid', command: 'run', transport: 'stdio', autoConnect: false, localOnly: true }] }, + }); + }); + + it('writes MCP servers to user and workspace-local destinations without overwriting existing entries', async () => { + const root = '/workspace'; + const userConfig = '/autohand/config.json'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root, configPath: userConfig }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => [path.join(HOME, '.claude.json'), path.join(root, '.mcp.json'), userConfig].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return { mcpServers: { user: { command: 'run' } }, projects: { [root]: { mcpServers: { + project: { command: 'shared' }, + keep: { command: 'incoming' }, + events: { type: 'sse', url: 'https://example.test/events', headers: { Authorization: 'Bearer secret' } }, + } } } } as never; + if (String(p) === path.join(root, '.mcp.json')) return { mcpServers: { project: { type: 'http', url: 'https://local' }, local: { command: 'local-run' } } } as never; + if (String(p) === userConfig) return { unrelated: true, mcp: { servers: [{ name: 'user', transport: 'stdio', command: 'existing' }] } } as never; + throw new Error('missing'); + }); + const result = await importer.import(['mcp']); + expect(result.imported.get('mcp')).toEqual({ + success: 4, + failed: 0, + skipped: 1, + skipReasons: { 'already exists': 1 }, + }); + const writes = atomicFileMocks.atomicWriteFile.mock.calls; + expect(writes.some(call => String(call[0]) === userConfig)).toBe(false); + const localWrite = JSON.parse(writes.find(call => String(call[0]).endsWith('.autohand/settings.local.json'))?.[1]); + expect(localWrite.mcp.servers).toEqual(expect.arrayContaining([ + { name: 'project', command: 'shared', transport: 'stdio', autoConnect: false, localOnly: true }, + { name: 'keep', command: 'incoming', transport: 'stdio', autoConnect: false, localOnly: true }, + { name: 'events', type: 'sse', url: 'https://example.test/events', headers: { Authorization: 'Bearer secret' }, transport: 'sse', autoConnect: false, localOnly: true }, + { name: 'local', command: 'local-run', transport: 'stdio', autoConnect: false, localOnly: true }, + ])); + expect(execFileSync).toHaveBeenCalledWith('git', ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], expect.objectContaining({ cwd: root })); + expect(fse.writeFile).toHaveBeenCalledWith('/workspace/.git/info/exclude', expect.stringContaining('**/.autohand/settings.local.json')); + }); + + it('reports MCP servers with Claude environment placeholders as unsupported', async () => { + const root = '/workspace'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => [path.join(HOME, '.claude.json'), path.join(root, '.mcp.json')].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) return {} as never; + if (String(p) === path.join(root, '.mcp.json')) return { mcpServers: { shared: { command: 'node', args: ['${SCRIPT:-server.js}'], env: { TOKEN: '${TOKEN}' } } } } as never; + throw new Error('missing'); + }); + + const result = await importer.import(['mcp']); + + expect(result.imported.get('mcp')).toMatchObject({ success: 0, failed: 1 }); + expect(result.errors).toEqual([expect.objectContaining({ category: 'mcp', item: 'shared', error: expect.stringContaining('environment variable placeholders') })]); + expect(fse.writeJson).not.toHaveBeenCalled(); + expect(atomicFileMocks.atomicWriteFile).not.toHaveBeenCalled(); + }); + + it('reports existing MCP servers as skipped without rewriting their config', async () => { + const root = '/workspace'; + const userConfig = '/autohand/config.json'; + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME, workspaceRoot: root, configPath: userConfig }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => + [path.join(HOME, '.claude.json'), userConfig].includes(String(p))); + vi.mocked(fse.readJson).mockImplementation(async (p: string) => { + if (String(p) === path.join(HOME, '.claude.json')) { + return { mcpServers: { existing: { command: 'server' } } } as never; + } + if (String(p) === userConfig) { + return { mcp: { servers: [{ name: 'existing', command: 'server' }] } } as never; + } + throw new Error('unexpected file read'); + }); + + const result = await importer.import(['mcp']); + + expect(result.imported.get('mcp')).toEqual({ + success: 0, + failed: 0, + skipped: 1, + skipReasons: { 'already exists': 1 }, + }); + expect(fse.ensureDir).not.toHaveBeenCalled(); + expect(fse.writeJson).not.toHaveBeenCalled(); + expect(atomicFileMocks.atomicWriteFile).not.toHaveBeenCalled(); + expect(fse.writeFile).not.toHaveBeenCalled(); + }); + + it('ignores malformed global Claude configuration', async () => { + importer = new ClaudeImporter({ sourceHome: CLAUDE_HOME }); + vi.mocked(fse.pathExists).mockImplementation(async (p: string) => String(p) === path.join(HOME, '.claude.json')); + vi.mocked(fse.readJson).mockRejectedValue(new SyntaxError('invalid JSON')); + + const result = await importer.scan(); + + expect(result.available.has('mcp')).toBe(false); + }); + it('should return empty available map when ~/.claude does not exist', async () => { vi.mocked(fse.pathExists).mockResolvedValue(false as never); diff --git a/tests/import/claudePermissions.test.ts b/tests/import/claudePermissions.test.ts new file mode 100644 index 000000000..845e1852f --- /dev/null +++ b/tests/import/claudePermissions.test.ts @@ -0,0 +1,330 @@ +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fixturePaths = vi.hoisted(() => ({ home: '/private/tmp/unused-claude-permission-fixture' })); +vi.mock('../../src/constants.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, AUTOHAND_PATHS: { ...actual.AUTOHAND_PATHS, get config() { return fixturePaths.home; } } }; +}); + +import { ClaudeImporter } from '../../src/import/importers/ClaudeImporter.js'; +import { loadConfig, saveConfig } from '../../src/config.js'; +import { trustWorkspace } from '../../src/permissions/workspaceTrust.js'; +import { runImport } from '../../src/import/index.js'; +import { PermissionManager } from '../../src/permissions/PermissionManager.js'; + +describe('Claude permission settings import', () => { + let root: string; + let sourceHome: string; + let workspaceRoot: string; + let secondRepository: string; + let configPath: string; + let legacyPath: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-permission-import-')); + sourceHome = path.join(root, 'claude'); + workspaceRoot = path.join(root, 'first-repo'); + secondRepository = path.join(root, 'second-repo-with-hyphens'); + fixturePaths.home = path.join(root, 'autohand'); + configPath = path.join(fixturePaths.home, 'config.json'); + legacyPath = path.join(fixturePaths.home, 'imported-claude-settings.json'); + await Promise.all([sourceHome, workspaceRoot, secondRepository, fixturePaths.home].map(directory => fs.ensureDir(directory))); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.remove(root); + }); + + const options = () => ({ sourceHome, workspaceRoot, configPath }); + + it('detects project permission settings when the Claude home and MCP configs are absent', async () => { + await fs.remove(sourceHome); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.json'), { permissions: { allow: ['Read'] } }); + await expect(new ClaudeImporter(options()).detect()).resolves.toBe(true); + }); + + it.each(['directory', 'file'])('keeps imported MCP credentials out of a symlinked project %s', async kind => { + await fs.outputJson(path.join(workspaceRoot, '.mcp.json'), { mcpServers: { private: { + command: 'fixture-server', env: { TOKEN: 'MCP_FIXTURE_CREDENTIAL' }, + } } }); + const outside = path.join(root, 'outside'); + await fs.ensureDir(outside); + const target = path.join(outside, 'settings.json'); + const original = '{"untouched":true}\n'; + if (kind === 'directory') await fs.symlink(outside, path.join(workspaceRoot, '.autohand'), 'junction'); + else { + await fs.ensureDir(path.join(workspaceRoot, '.autohand')); + await fs.writeFile(target, original); + await fs.symlink(target, path.join(workspaceRoot, '.autohand', 'settings.local.json')); + } + const result = await new ClaudeImporter(options()).import(['mcp']); + expect(result.errors).toContainEqual(expect.objectContaining({ category: 'mcp' })); + if (kind === 'directory') expect(await fs.readdir(outside)).toEqual([]); + else expect(await fs.readFile(target, 'utf8')).toBe(original); + }); + + it.skipIf(process.platform === 'win32')('creates private project MCP configuration files', async () => { + await fs.outputJson(path.join(workspaceRoot, '.mcp.json'), { mcpServers: { private: { command: 'fixture-server' } } }); + const result = await new ClaudeImporter(options()).import(['mcp']); + expect(result.errors).toEqual([]); + expect((await fs.stat(path.join(workspaceRoot, '.autohand', 'settings.local.json'))).mode & 0o777).toBe(0o600); + }); + + async function seed() { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { + env: { API_KEY: 'SOURCE_ENV_MUST_NOT_BE_COPIED' }, + permissions: { + allow: ['Bash(npm test:*)', 'Bash(git status)', 'Read(./src/**)', 'mcp__docs__search', 'WebFetch(domain:example.com)', 'UnknownTool(secret)'], + deny: ['Bash(rm:*)', 'Bash(npm test:*)'], + }, + }); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.json'), { permissions: { allow: ['Bash(git:*)'] } }); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.local.json'), { + env: { TOKEN: 'PROJECT_ENV_MUST_NOT_BE_COPIED' }, + permissions: { allow: ['Edit(./src/**)'], deny: ['Write(./src/private/**)'] }, + }); + await fs.outputJson(path.join(secondRepository, '.claude', 'settings.json'), { + permissions: { allow: ['Write(./lib/**)'], deny: ['Bash(git push:*)'] }, + }); + await fs.outputFile(path.join(sourceHome, 'projects', 'encoded-name-is-not-a-path', 'session.jsonl'), + JSON.stringify({ type: 'user', cwd: secondRepository, message: { content: 'Only the recorded cwd identifies this repo.' } }) + '\n'); + await fs.writeJson(configPath, { + provider: 'openrouter', retained: { value: true }, + permissions: { allowList: ['run_command:existing*'], denyList: ['shell:already-blocked*'], allowPatterns: [{ kind: 'run_command' }] }, + }); + await fs.outputJson(path.join(workspaceRoot, '.autohand', 'settings.local.json'), { + version: 1, agent: { keepMe: true }, permissions: { allowList: ['read_file:existing/**'] }, + }); + await fs.writeJson(legacyPath, { importedFrom: 'claude', raw: { env: { TOKEN: 'LEGACY_ENV_MUST_BE_REMOVED' } } }); + await fs.chmod(legacyPath, 0o644); + } + + async function snapshot(directory = root): Promise { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const result: unknown[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(directory, entry.name), stat = await fs.stat(file); + result.push([path.relative(root, file), stat.mode & 0o777, stat.mtimeMs, entry.isDirectory() ? null : await fs.readFile(file, 'utf8')]); + if (entry.isDirectory()) result.push(...await snapshot(file)); + } + return result; + } + + it('merges translated user and per-repo rules, preserves existing settings and never copies environment data', async () => { + await seed(); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(result.errors).toEqual([]); + expect(result.permissions).toMatchObject({ allow: 7, deny: 4, repositories: 2, skipped: 2, legacySettingsScrubbed: true }); + + const user = await fs.readJson(configPath); + expect(user).toMatchObject({ provider: 'openrouter', retained: { value: true } }); + expect(user.permissions.allowList).toEqual(expect.arrayContaining([ + 'run_command:existing*', 'run_command:npm test*', 'shell:npm test*', 'run_command:git status', 'read_file:src/**', 'mcp__docs__search:*', + ])); + expect(user.permissions.denyList).toEqual(expect.arrayContaining(['shell:already-blocked*', 'run_command:rm*', 'shell:rm*', 'run_command:npm test*'])); + const first = await fs.readJson(path.join(workspaceRoot, '.autohand', 'settings.local.json')); + expect(first).toMatchObject({ version: 1, agent: { keepMe: true } }); + expect(first.permissions.allowList).toEqual(expect.arrayContaining(['read_file:existing/**', 'run_command:git*', 'write_file:src/**'])); + expect(first.permissions.denyList).toContain('write_file:src/private/**'); + expect((await fs.readJson(path.join(secondRepository, '.autohand', 'settings.local.json'))).permissions.allowList).toContain('write_file:lib/**'); + expect(JSON.stringify(result.permissions?.mappings)).toContain('no equivalent'); + for (const directory of [fixturePaths.home, path.join(workspaceRoot, '.autohand'), path.join(secondRepository, '.autohand')]) { + for (const name of await fs.readdir(directory)) { + const contents = await fs.readFile(path.join(directory, name), 'utf8'); + expect(contents).not.toMatch(/SOURCE_ENV_MUST_NOT_BE_COPIED|PROJECT_ENV_MUST_NOT_BE_COPIED|LEGACY_ENV_MUST_BE_REMOVED/); + } + } + expect(await fs.readJson(legacyPath)).not.toHaveProperty('raw'); + expect((await fs.stat(legacyPath)).mode & 0o777).toBe(0o600); + }); + + it('honours imported deny rules over existing broad user allows and project allows', async () => { + await seed(); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + await manager.initLocalSettings(); + expect(manager.checkPermission({ tool: 'run_command', command: 'rm notes.txt' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'shell', command: 'rm notes.txt' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'run_command', command: 'npm test --unit' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'run_command', command: 'git status' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'write_file', path: 'src/public.ts' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'write_file', path: 'src/private/token.ts' }).allowed).toBe(false); + }); + + it('prints a dry-run mapping table and cleanup notice without creating or changing any file', async () => { + await seed(); + const before = await snapshot(); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runImport({ ...options(), source: 'claude', categories: ['settings'], dryRun: true }); + expect(await snapshot()).toEqual(before); + const printed = output.mock.calls.flat().join('\n'); + expect(printed).toContain('Bash(npm test:*)'); + expect(printed).toContain('run_command:npm test*'); + expect(printed).toContain('skipped (no equivalent)'); + expect(printed).toContain('Would scrub'); + expect(printed).not.toMatch(/SOURCE_ENV_MUST_NOT_BE_COPIED|PROJECT_ENV_MUST_NOT_BE_COPIED|LEGACY_ENV_MUST_BE_REMOVED/); + }); + + it('is idempotent across permission writes and legacy cleanup', async () => { + await seed(); + const importer = new ClaudeImporter(options()); + await importer.import(['settings']); + const before = await snapshot(); + await importer.import(['settings']); + expect(await snapshot()).toEqual(before); + }); + + it('discovers project-only permission settings even without a Claude home or hooks', async () => { + await fs.remove(sourceHome); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.local.json'), { permissions: { allow: ['Bash(git status)'] } }); + const importer = new ClaudeImporter(options()); + expect(await importer.detect()).toBe(true); + expect((await importer.scan()).available.get('settings')?.count).toBe(1); + expect((await importer.import(['settings'])).errors).toEqual([]); + expect((await fs.readJson(path.join(workspaceRoot, '.autohand', 'settings.local.json'))).permissions.allowList).toContain('run_command:git status'); + }); + + it('does not overwrite malformed Autohand configuration or claim translated user rules', async () => { + await seed(); + await fs.writeFile(configPath, '{not valid JSON'); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(await fs.readFile(configPath, 'utf8')).toBe('{not valid JSON'); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.permissions?.allow).toBe(3); + expect(result.permissions?.deny).toBe(2); + }); + it('checks every shell subcommand and never grants unparsed shell syntax from a prefix', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(npm test:*)', 'Bash(git status)'], deny: ['Bash(rm:*)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + expect(manager.checkPermission({ tool: 'run_command', command: 'npm test --unit' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'run_command', command: 'npm test && git status' }).allowed).toBe(false); + for (const command of ['npm test && rm notes.txt', 'npm test; python3 malicious.py', 'npm test $(rm notes.txt)', 'npm test `rm notes.txt`', 'npm test > secret.txt', 'npm test && "r"m notes.txt', 'npm test && bash -c "rm notes.txt"']) { + expect(manager.checkPermission({ tool: 'run_command', command }).allowed, command).toBe(false); + } + }); + + it('skips file patterns whose segment or root semantics have no equivalent', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Edit(./src/*.ts)', 'Read(secrets/**)', 'Write(/src/**)', 'Edit(./src/**.ts)', 'Read(./src/**/secret.txt)'] } }); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(result.permissions).toMatchObject({ allow: 0, skipped: 5 }); + expect(await fs.pathExists(configPath)).toBe(false); + }); + + it('refuses a project settings directory that escapes through a symlink', async () => { + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.json'), { permissions: { allow: ['Bash(git status)'] } }); + const outside = path.join(root, 'outside'); + await fs.ensureDir(outside); + await fs.symlink(outside, path.join(workspaceRoot, '.autohand')); + const result = await new ClaudeImporter(options()).import(['settings']); + expect(result.errors.length).toBeGreaterThan(0); + expect(await fs.readdir(outside)).toEqual([]); + }); + + it('shows sanitized source errors in dry-run and scrubs legacy data on session-only import', async () => { + await seed(); + await fs.writeFile(path.join(sourceHome, 'settings.json'), '{"env":{"TOKEN":"SECRET_PARSE_VALUE"}, broken'); + const importer = new ClaudeImporter(options()); + const preview = await importer.previewSettings(); + expect(preview.errors?.length).toBeGreaterThan(0); + expect(JSON.stringify(preview.errors)).not.toContain('SECRET_PARSE_VALUE'); + const result = await importer.import(['sessions']); + expect(result.permissions?.legacySettingsScrubbed).toBe(true); + expect(await fs.readFile(legacyPath, 'utf8')).not.toContain('LEGACY_ENV_MUST_BE_REMOVED'); + }); + + it('keeps unsupported shell syntax denied under hard restrictions and checks absolute file denies', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(git:*)', 'Bash(timeout:*)', 'Read'], deny: ['Bash(rm:*)', 'Read(./secrets.txt)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const settings = (await fs.readJson(configPath)).permissions; + const manager = new PermissionManager({ settings, workspaceRoot }); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'secrets.txt') })).toMatchObject({ allowed: false, reason: 'deny_list' }); + for (const command of ['rm file > out', 'git status && rm file', '"git status"', 'timeout 1 rm file', 'git commit -m "unsafe"', 'npm test %ARGS%', 'find . -delete', 'watch rm file']) { + expect(manager.checkPermission({ tool: 'run_command', command })).toMatchObject({ allowed: false, reason: 'deny_list' }); + } + const restricted = new PermissionManager({ settings: { mode: 'restricted', allowList: ['run_command:git*'] } }); + expect(restricted.checkPermission({ tool: 'run_command', command: 'git status > out' })).toMatchObject({ allowed: false, reason: 'mode_restricted' }); + }); + + it('preserves Claude word boundaries and case without changing pre-existing Autohand rules', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(git:*)', 'Read(./src/**)'], deny: ['Read(./secret.txt)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + expect(manager.checkPermission({ tool: 'run_command', command: 'git status' }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'run_command', command: 'gitfoo' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'run_command', command: 'GIT status' }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'src/ok.txt') }).allowed).toBe(true); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'SRC/ok.txt') }).allowed).toBe(false); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'secret.txt') })).toMatchObject({ allowed: false, reason: 'deny_list' }); + const previous = new PermissionManager({ settings: { allowList: ['run_command:git*'] }, workspaceRoot }); + expect(previous.checkPermission({ tool: 'run_command', command: 'gitfoo' }).allowed).toBe(true); + }); + + it('keeps global and project rule identity through actual config loading, before async reload', async () => { + await seed(); + await new ClaudeImporter(options()).import(['settings']); + const config = await fs.readJson(configPath); + config.permissions.denyList.push('run_command:git*'); + await fs.writeJson(configPath, config); + await fs.outputJson(path.join(workspaceRoot, '.claude', 'settings.local.json'), { permissions: { deny: ['Bash(git:*)', 'Read(./**)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const workspaceTrustStorePath = path.join(root, 'trust.json'); + const loadOptions = { initializeTheme: false, workspaceTrustStorePath }; + const initial = await loadConfig(configPath, workspaceRoot, loadOptions); + if (initial.workspaceTrust && !initial.workspaceTrust.trusted) await trustWorkspace(workspaceRoot, initial.workspaceTrust.fingerprint, workspaceTrustStorePath); + const loaded = await loadConfig(configPath, workspaceRoot, loadOptions); + const manager = new PermissionManager({ settings: loaded.permissions, workspaceRoot }); + expect(manager.checkPermission({ tool: 'run_command', command: 'rm notes.txt' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool: 'run_command', command: 'gitfoo' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool: 'read_file', path: path.join(workspaceRoot, 'anything.txt') })).toMatchObject({ allowed: false, reason: 'project_deny_list' }); + await manager.initLocalSettings(); + expect(manager.checkPermission({ tool: 'run_command', command: 'gitfoo' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + await saveConfig(loaded); + expect((await fs.readJson(configPath)).permissions).not.toHaveProperty('workspaceScopes'); + const other = await loadConfig(configPath, secondRepository, loadOptions); + const otherManager = new PermissionManager({ settings: other.permissions, workspaceRoot: secondRepository }); + expect(otherManager.checkPermission({ tool: 'read_file', path: path.join(secondRepository, 'anything.txt') }).reason).not.toBe('project_deny_list'); + }); + + it('checks both requested and resolved paths for existing and new files behind repository symlinks', async () => { + await fs.ensureDir(path.join(workspaceRoot, 'public')); + await fs.ensureDir(path.join(workspaceRoot, 'secrets')); + await fs.writeFile(path.join(workspaceRoot, 'secrets', 'token.txt'), 'fixture'); + await fs.symlink('../secrets', path.join(workspaceRoot, 'public', 'link')); + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Read(./public/**)', 'Write(./public/**)'], deny: ['Read(./secrets/**)', 'Write(./secrets/**)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot }); + for (const tool of ['read_file', 'write_file']) { + expect(manager.checkPermission({ tool, path: 'public/link/token.txt' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool, path: 'public/link/new.txt' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(manager.checkPermission({ tool, path: 'public/safe.txt' }).allowed).toBe(true); + } + }); + + it('does not retain source metadata after removing, saving and re-adding an ordinary rule', async () => { + await fs.outputJson(path.join(sourceHome, 'settings.json'), { permissions: { allow: ['Bash(npm test:*)'], deny: ['Bash(git:*)'] } }); + await new ClaudeImporter(options()).import(['settings']); + const manager = new PermissionManager({ settings: (await fs.readJson(configPath)).permissions, workspaceRoot, + onPersist: async permissions => { await fs.writeJson(configPath, { permissions }); } }); + await manager.removeFromAllowList('run_command:npm test*'); + await manager.removeFromDenyList('run_command:git*'); + const settings = (await fs.readJson(configPath)).permissions; + expect(settings.importedClaudeRules.some((rule: { pattern: string }) => rule.pattern === 'run_command:git*' || rule.pattern === 'run_command:npm test*')).toBe(false); + const reloaded = new PermissionManager({ settings, workspaceRoot }); + reloaded.addToAllowList('run_command:npm test*'); + reloaded.addToDenyList('run_command:git*'); + expect(reloaded.checkPermission({ tool: 'run_command', command: 'gitfoo' })).toMatchObject({ allowed: false, reason: 'deny_list' }); + expect(reloaded.checkPermission({ tool: 'run_command', command: 'npm testfoo' }).allowed).toBe(true); + }); + + it('ignores forged saved runtime scopes in a minimal configuration', async () => { + await fs.writeJson(configPath, { permissions: { workspaceScopes: [{ denyList: ['read_file:*'] }] } }); + const loaded = await loadConfig(configPath, workspaceRoot, { initializeTheme: false, workspaceTrustStorePath: path.join(root, 'trust.json') }); + expect(loaded.permissions).not.toHaveProperty('workspaceScopes'); + }); + +}); diff --git a/tests/import/dryRunAll.test.ts b/tests/import/dryRunAll.test.ts new file mode 100644 index 000000000..27c522c77 --- /dev/null +++ b/tests/import/dryRunAll.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +const fixtures = vi.hoisted(() => ({ imports: vi.fn(), preview: vi.fn() })); +vi.mock('../../src/import/registry.js', () => ({ ImporterRegistry: class { + async detectAvailable() { return [ + { name: 'claude', displayName: 'Claude Code', scan: async () => ({ available: new Map([['settings', { count: 1, description: 'Settings' }]]) }), import: fixtures.imports, + previewSettings: async () => { fixtures.preview(); return { allow: 1, deny: 1, repositories: 0, skipped: 0, mappings: [], legacySettingsScrubbed: false, legacySettingsToScrub: [] }; } }, + { name: 'codex', displayName: 'Codex', scan: async () => ({ available: new Map([['settings', { count: 1, description: 'Settings' }]]) }), import: fixtures.imports }, + ]; } +} })); +import { runImport } from '../../src/import/index.js'; +describe('dry run across all detected sources', () => { + it('previews selected categories and mappings without invoking any import', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + fixtures.imports.mockResolvedValue({ source: 'fixture', imported: new Map(), errors: [], duration: 0 }); + try { + await runImport({ all: true, dryRun: true, categories: ['settings'] }); + expect(fixtures.imports).not.toHaveBeenCalled(); + expect(fixtures.preview).toHaveBeenCalledOnce(); + expect(output.mock.calls.flat().join('\n')).toContain('Would translate 1 allow and 1 deny'); + } finally { output.mockRestore(); } + }); +}); diff --git a/tests/modes/acp/adapter.test.ts b/tests/modes/acp/adapter.test.ts index 65399d82b..07f988b51 100644 --- a/tests/modes/acp/adapter.test.ts +++ b/tests/modes/acp/adapter.test.ts @@ -156,6 +156,7 @@ vi.mock("../../../src/actions/filesystem.js", () => ({ vi.mock("../../../src/utils/sessionWorktree.js", () => ({ prepareSessionWorktree: mockPrepareSessionWorktree, isSessionWorktreeEnabled: mockIsSessionWorktreeEnabled, + cleanupSessionWorktree: vi.fn().mockResolvedValue('Removed worktree fixture'), })); vi.mock("../../../src/core/conversationManager.js", () => ({ @@ -801,6 +802,18 @@ describe("AutohandAcpAdapter", () => { }); expect(mockFileActionManager).toHaveBeenCalledWith("/workspace-worktree"); }); + + it.each(['dispose', 'shutdown'] as const)('safely cleans up worktrees after ACP %s releases agents', async method => { + const { cleanupSessionWorktree } = await import('../../../src/utils/sessionWorktree.js'); + adapter = new AutohandAcpAdapter(connection, { worktree: true }); + await adapter.initialize(makeInitRequest()); + await adapter.newSession(makeNewSessionRequest()); + await adapter[method](); + expect(cleanupSessionWorktree).toHaveBeenCalledOnce(); + expect(cleanupSessionWorktree).toHaveBeenCalledWith(mockPrepareSessionWorktree.mock.results[0].value); + expect(mockAgent.shutdownRuntimeResources.mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(cleanupSessionWorktree).mock.invocationCallOrder[0]); + }); }); // ------------------------------------------------------------------------- diff --git a/tests/modes/acp/types.test.ts b/tests/modes/acp/types.test.ts index fd20c1bbc..855caeacd 100644 --- a/tests/modes/acp/types.test.ts +++ b/tests/modes/acp/types.test.ts @@ -510,7 +510,7 @@ describe("resolveDefaultModel()", () => { }, } as any); - expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5"); + expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5-5"); }); it("returns model for autohandai provider settings when autohand_inference is enabled", () => { @@ -531,7 +531,7 @@ describe("resolveDefaultModel()", () => { it("returns fallback model when provider config has no model", () => { const config = makeConfig({ openrouter: undefined } as any); - expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5"); + expect(resolveDefaultModel(config)).toBe("anthropic/claude-sonnet-5-5"); }); it("defaults to openrouter when provider is not specified", () => { diff --git a/tests/modes/rpc/index.debugLogging.spec.ts b/tests/modes/rpc/index.debugLogging.spec.ts index 0bcc790dc..31b502a28 100644 --- a/tests/modes/rpc/index.debugLogging.spec.ts +++ b/tests/modes/rpc/index.debugLogging.spec.ts @@ -32,6 +32,7 @@ vi.mock('../../../src/startup/checks.js', () => ({ vi.mock('../../../src/utils/sessionWorktree.js', () => ({ isSessionWorktreeEnabled: vi.fn().mockReturnValue(false), prepareSessionWorktree: vi.fn(), + cleanupSessionWorktree: vi.fn().mockResolvedValue('Removed worktree fixture'), })); vi.mock('../../../src/providers/ProviderFactory.js', () => ({ ProviderFactory: { create: vi.fn().mockReturnValue({}) }, @@ -114,4 +115,15 @@ describe('runRpcMode debug logging', () => { expect(diagnostics).not.toContain('RAW_JSON_SENTINEL'); expect(rpcMocks.writeErrorResponse).toHaveBeenCalledWith(null, -32600, 'Invalid request'); }); + + it('safely cleans up a --worktree session when the RPC input closes', async () => { + const { isSessionWorktreeEnabled, prepareSessionWorktree, cleanupSessionWorktree } = await import('../../../src/utils/sessionWorktree.js'); + const info = { repoRoot: '/fixture', worktreePath: '/fixture-session', branchName: 'session', createdBranch: true, baseRef: 'refs/heads/main' }; + vi.mocked(isSessionWorktreeEnabled).mockReturnValueOnce(true); + vi.mocked(prepareSessionWorktree).mockReturnValueOnce(info); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await expect(runRpcMode({ worktree: true } as never)).resolves.toBe(0); + expect(cleanupSessionWorktree).toHaveBeenCalledWith(info); + expect(stderr.mock.calls.map(([value]) => String(value)).join('')).toContain('Removed worktree fixture'); + }); }); diff --git a/tests/package/keywords.test.ts b/tests/package/keywords.test.ts new file mode 100644 index 000000000..c2e17dad0 --- /dev/null +++ b/tests/package/keywords.test.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { keywords?: string[] }; + +describe('npm discoverability', () => { + it('lists the keywords people search for', () => { + for (const keyword of ['coding-agent', 'ai-coding-agent', 'claude-code', 'codex', 'mcp', 'ollama', 'byok', 'terminal']) { + expect(pkg.keywords).toContain(keyword); + } + }); + + it('keeps the existing keywords', () => { + for (const keyword of ['cli', 'llm', 'agent', 'agentic', 'autohand']) { + expect(pkg.keywords).toContain(keyword); + } + }); +}); diff --git a/tests/packageLicense.test.ts b/tests/packageLicense.test.ts new file mode 100644 index 000000000..d0396f942 --- /dev/null +++ b/tests/packageLicense.test.ts @@ -0,0 +1,17 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +describe('npm package licensing documents', () => { + it('ships the Apache licence, commercial terms and README', () => { + const output = execFileSync('bun', ['pm', 'pack', '--dry-run', '--ignore-scripts'], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); + for (const name of ['LICENSE', 'COMMERCIAL.md', 'README.md']) { + expect(output.split('\n').some(line => line.startsWith('packed ') && line.endsWith(` ${name}`)), name).toBe(true); + } + }); +}); diff --git a/tests/permissions/planEditApproval.test.ts b/tests/permissions/planEditApproval.test.ts new file mode 100644 index 000000000..07d35d390 --- /dev/null +++ b/tests/permissions/planEditApproval.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { confirmAgentDangerousAction } from '../../src/core/agent/AgentCommandRuntime.js'; +import { PermissionManager } from '../../src/permissions/PermissionManager.js'; +import { getPermissionPolicyDisposition } from '../../src/permissions/types.js'; + +describe('accepted-plan permissions', () => { + it('does not let YOLO or auto-confirm skip the selected manual approval callback', async () => { + const manager = new PermissionManager({ mode: 'unrestricted' }); + manager.setPlanEditApproval('manual'); + const confirmationCallback = vi.fn(async () => ({ decision: 'deny_once' as const })); + const host = { runtime: { options: { yes: true, unrestricted: true, yolo: 'allow:*' }, config: { ui: { autoConfirm: true } } }, permissionManager: manager, confirmationCallback, hookManager: { executeHooks: vi.fn(async () => []) } }; + await expect(confirmAgentDangerousAction(host, 'Write main.ts', { tool: 'write_file', path: '/repo/main.ts' })).resolves.toMatchObject({ decision: 'deny_once' }); + expect(confirmationCallback).toHaveBeenCalledOnce(); + }); + it('auto-accepts file edits without granting shell, dependency or git approvals', () => { + const manager = new PermissionManager({ mode: 'interactive' }); + manager.setPlanEditApproval('auto'); + for (const tool of ['write_file', 'append_file', 'apply_patch', 'search_replace', 'notebook_edit', 'format_file', 'multi_file_edit', 'delete_path', 'rename_path', 'copy_path']) { + expect(manager.checkPermission({ tool: 'write_file', requestedTool: tool, path: '/repo/main.ts' }).allowed).toBe(true); + } + for (const tool of ['run_command', 'shell', 'git_checkout', 'add_dependency', 'remove_dependency']) { + expect(manager.checkPermission({ tool: tool === 'run_command' || tool === 'shell' ? tool : 'write_file', requestedTool: tool, command: 'npm test', path: '/repo/package.json' }).allowed).toBe(false); + } + manager.setPlanEditApproval(undefined); + expect(manager.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + }); + it('manual approval overrides YOLO, saved approvals and broad allow patterns', async () => { + const manager = new PermissionManager({ mode: 'unrestricted', rememberSession: true, allowList: ['write_file:*'], allPathsAllowed: true }); + manager.setPlanEditApproval('manual'); + const edit = { tool: 'write_file', path: '/repo/main.ts' }; + await manager.applyPromptDecision(edit, { decision: 'allow_session' }); + expect(getPermissionPolicyDisposition(manager.checkPermission(edit))).toBe('prompt'); + manager.setPlanEditApproval(undefined); + expect(manager.checkPermission(edit).allowed).toBe(true); + }); + it('never overrides a blacklist, explicit deny or read-only mode', () => { + const denied = new PermissionManager({ denyList: ['write_file:*'] }); + denied.setPlanEditApproval('auto'); + expect(denied.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + expect(denied.checkPermission({ tool: 'write_file', path: '.env' }).reason).toBe('blacklisted'); + const restricted = new PermissionManager({ mode: 'restricted' }); + restricted.setPlanEditApproval('auto'); + expect(restricted.checkPermission({ tool: 'write_file', path: '/repo/main.ts' }).allowed).toBe(false); + }); +}); diff --git a/tests/permissions/workspaceTrust.test.ts b/tests/permissions/workspaceTrust.test.ts index 8244f77d2..86986df6c 100644 --- a/tests/permissions/workspaceTrust.test.ts +++ b/tests/permissions/workspaceTrust.test.ts @@ -8,11 +8,14 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + collectProjectToolEntries, computeWorkspaceTrustFingerprint, isWorkspaceTrusted, trustWorkspace, } from '../../src/permissions/workspaceTrust.js'; +const SCRATCH_DIR = os.tmpdir(); + let tempDir: string; let storePath: string; let workspaceRoot: string; @@ -58,6 +61,43 @@ describe('computeWorkspaceTrustFingerprint', () => { expect(changedServer).not.toBe(original); expect(changedHook).not.toBe(changedServer); }); + + it('includes project tool files in the trust fingerprint', async () => { + const root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'trust-tools-')); + try { + const toolFile = path.join(root, '.autohand', 'tools', 'deploy.json'); + await fs.outputFile(toolFile, '{"name":"deploy","description":"d","handler":"echo d"}'); + const tools = await collectProjectToolEntries(root); + expect(tools).toEqual([{ file: '.autohand/tools/deploy.json', sha256: expect.stringMatching(/^[a-f0-9]{64}$/) }]); + + const a = computeWorkspaceTrustFingerprint({ hooks: [], mcpServers: [], tools }); + await fs.writeFile(toolFile, '{"name":"deploy","description":"d","handler":"echo changed"}'); + const b = computeWorkspaceTrustFingerprint({ hooks: [], mcpServers: [], tools: await collectProjectToolEntries(root) }); + + expect(a).not.toBe(b); + } finally { + await fs.remove(root); + } + }); + + it('lists tool files sorted by name and ignores non-JSON files and a missing tools dir', async () => { + const root = await fs.mkdtemp(path.join(SCRATCH_DIR, 'trust-tools-')); + try { + expect(await collectProjectToolEntries(root)).toEqual([]); + + const toolsDir = path.join(root, '.autohand', 'tools'); + await fs.outputFile(path.join(toolsDir, 'zeta.json'), '{"name":"zeta"}'); + await fs.outputFile(path.join(toolsDir, 'alpha.json'), '{"name":"alpha"}'); + await fs.outputFile(path.join(toolsDir, 'README.md'), 'not a tool'); + + expect((await collectProjectToolEntries(root)).map((tool) => tool.file)).toEqual([ + '.autohand/tools/alpha.json', + '.autohand/tools/zeta.json', + ]); + } finally { + await fs.remove(root); + } + }); }); describe('workspace trust store', () => { diff --git a/tests/providers/AutohandAIProvider.test.ts b/tests/providers/AutohandAIProvider.test.ts index f535599a1..42ef80490 100644 --- a/tests/providers/AutohandAIProvider.test.ts +++ b/tests/providers/AutohandAIProvider.test.ts @@ -69,7 +69,7 @@ describe("AutohandAIProvider", () => { apiKey: "test-autohand-key", model, })).toThrow( - "Weka is available through the Autohand API and Console Playground. CLI execution is not supported yet.", + "Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.", ); }, ); @@ -88,7 +88,7 @@ describe("AutohandAIProvider", () => { model: "weka", messages: [{ role: "user", content: "Decide" }], })).rejects.toThrow( - "Weka is available through the Autohand API and Console Playground. CLI execution is not supported yet.", + "Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.", ); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/tests/providers/LLMGatewayClient.spec.ts b/tests/providers/LLMGatewayClient.spec.ts index 041320fb0..0c3d17796 100644 --- a/tests/providers/LLMGatewayClient.spec.ts +++ b/tests/providers/LLMGatewayClient.spec.ts @@ -1534,3 +1534,19 @@ describe('LLMGatewayClient', () => { }); }); }); + + +describe('Autohand reset-credit guidance', () => { + afterEach(() => vi.unstubAllGlobals()); + it.each(['window_5h', 'window_24h', 'window_week'])('renders a trusted billing reset link for %s', async scope => { + const resetUrl = 'https://console.autohand.ai/billing?account=team_1&from=cli-limit'; + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: { type: 'rate_limited', scope, message: 'Window exhausted', resetAt: 1900000000, resetUrl } }, { status: 429 }))); + const client = new LLMGatewayClient({ apiKey: 'test-key', model: 'fantail' }, { maxRetries: 0 }, { serviceName: 'Autohand AI', credentialName: 'API key', accountName: 'account' }); + await expect(client.complete({ messages: [{ role: 'user', content: 'hello' }] })).rejects.toThrow(`Skip the wait: use 100 reset credits to reset this window now. Packs start at US$10: ${resetUrl}`); + }); + it.each(['https://evil.test/billing', 'http://console.autohand.ai/billing', 'https://console.autohand.ai/upgrade/'])('does not render an untrusted reset destination %s', async resetUrl => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: { type: 'rate_limited', scope: 'window_5h', resetUrl } }, { status: 429 }))); + const client = new LLMGatewayClient({ apiKey: 'test-key', model: 'fantail' }, { maxRetries: 0 }, { serviceName: 'Autohand AI', credentialName: 'API key', accountName: 'account' }); + await expect(client.complete({ messages: [{ role: 'user', content: 'hello' }] })).rejects.not.toThrow('Skip the wait:'); + }); +}); diff --git a/tests/providers/cliCatalogPolicy.test.ts b/tests/providers/cliCatalogPolicy.test.ts new file mode 100644 index 000000000..4881351f5 --- /dev/null +++ b/tests/providers/cliCatalogPolicy.test.ts @@ -0,0 +1,32 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +const previousCatalog = vi.hoisted(() => { + const previous = { catalog: process.env.AUTOHAND_MODELS_CATALOG, home: process.env.AUTOHAND_HOME }; + process.env.AUTOHAND_MODELS_CATALOG = process.cwd() + '/src/providers/models.json'; + process.env.AUTOHAND_HOME = process.cwd() + '/.f0257-hermetic-home'; + return previous; +}); +afterAll(() => { + if (previousCatalog.catalog === undefined) delete process.env.AUTOHAND_MODELS_CATALOG; + else process.env.AUTOHAND_MODELS_CATALOG = previousCatalog.catalog; + if (previousCatalog.home === undefined) delete process.env.AUTOHAND_HOME; + else process.env.AUTOHAND_HOME = previousCatalog.home; +}); +import { getProviderModelOptions, getProviderRunnableModelOptions, getProviderDefaultModel, getProviderRuntimeDefaultModel } from '../../src/providers/modelCatalog.js'; +import { getAutohandAICloudModelCliUnsupportedReason } from '../../src/providers/AutohandAIProvider.js'; + +describe('CLI hosted model availability', () => { + it('does not list Weka even for clients that ignore cliSupported', () => { + expect(getProviderModelOptions('autohandai').some(model => model.id === 'weka')).toBe(false); + expect(getProviderRunnableModelOptions('autohandai').some(model => model.id === 'weka')).toBe(false); + }); + it.each(['weka', 'autohand/weka', 'autohandai/weka'])('rejects %s without depending on a catalog entry', model => { + expect(getAutohandAICloudModelCliUnsupportedReason(model)).toBe('Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.'); + }); + it.each(['anthropic', 'openrouter'])('offers the Claude 5 family with Sonnet 5.5 first on %s', provider => { + const prefix = provider === 'openrouter' ? 'anthropic/' : ''; + const models = getProviderModelOptions(provider).map(model => model.id); + expect(models.slice(0, 4)).toEqual(['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'].map(id => prefix + id)); + expect(getProviderDefaultModel(provider)).toBe(prefix + 'claude-sonnet-5-5'); + expect(getProviderRuntimeDefaultModel(provider)).toBe(prefix + 'claude-sonnet-5-5'); + }); +}); diff --git a/tests/providers/modelCatalog.test.ts b/tests/providers/modelCatalog.test.ts index 9f3e6c02c..28ad1795d 100644 --- a/tests/providers/modelCatalog.test.ts +++ b/tests/providers/modelCatalog.test.ts @@ -43,6 +43,10 @@ describe("modelCatalog", () => { "gpt-5.6-luna", "gpt-5.4", ])); + for (const id of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-astra', 'gpt-6.1-sol']) { + expect(getProviderModelOptions('openai')).toContainEqual(expect.objectContaining({ id, contextWindow: 272_000 })); + } + expect(getProviderDefaultModel('openai')).toBe('gpt-5.4'); expect(getProviderDefaultModel("xai")).toBe("grok-4.5"); expect(getProviderModelIds("xai")).toEqual(expect.arrayContaining([ "grok-4.5", @@ -51,17 +55,17 @@ describe("modelCatalog", () => { "grok-4.20-reasoning", ])); expect(getProviderDefaultModel("autohandai")).toBe("moa"); - expect(getProviderDefaultModel("anthropic")).toBe("claude-sonnet-5"); + expect(getProviderDefaultModel("anthropic")).toBe("claude-sonnet-5-5"); expect(getProviderModelIds("anthropic")).toEqual(expect.arrayContaining([ - "claude-sonnet-5", + "claude-sonnet-5-5", "claude-opus-5", "claude-fable-5", "claude-opus-4-8", "claude-haiku-4-5", ])); - expect(getProviderDefaultModel("openrouter")).toBe("anthropic/claude-sonnet-5"); + expect(getProviderDefaultModel("openrouter")).toBe("anthropic/claude-sonnet-5-5"); expect(getProviderModelIds("openrouter")).toEqual(expect.arrayContaining([ - "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-5-5", "anthropic/claude-opus-5", "anthropic/claude-fable-5", "anthropic/claude-opus-4.8", @@ -81,13 +85,6 @@ describe("modelCatalog", () => { expect.objectContaining({ id: "muse-wonder-1.0", contextWindow: 262_144, maxTokens: 16_000, toolCalls: true }), expect.objectContaining({ id: "qwen3.8-max", contextWindow: 262_144, maxTokens: 16_000, toolCalls: true }), expect.objectContaining({ id: "gemini-4-argon", contextWindow: 1_048_576, maxTokens: 16_000, toolCalls: true }), - expect.objectContaining({ - id: "weka", - contextWindow: 32_000, - maxTokens: 1_024, - cliSupported: false, - description: expect.stringContaining("not runnable in the CLI"), - }), ]); expect(getProviderRunnableModelIds("autohandai")).toEqual(["fantail", "moa", "auto", "gpt-5.6-luna", "deepseek-v4-flash", "gpt-6-sol", "grok-4.7", "qwen3.8-27b", "muse-wonder-1.0", "qwen3.8-max", "gemini-4-argon"]); rmSync(dir, { recursive: true, force: true }); diff --git a/tests/providers/openRouterHeaders.test.ts b/tests/providers/openRouterHeaders.test.ts new file mode 100644 index 000000000..fa794cc35 --- /dev/null +++ b/tests/providers/openRouterHeaders.test.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OPENROUTER_ATTRIBUTION_HEADERS, OpenRouterClient } from '../../src/providers/OpenRouterClient.js'; +import { clearModelCapabilitiesCache } from '../../src/providers/modelCapabilities.js'; + +describe('OpenRouter attribution headers', () => { + beforeEach(() => { + clearModelCapabilitiesCache(); + }); + + afterEach(() => { + clearModelCapabilitiesCache(); + vi.restoreAllMocks(); + }); + + it('names Autohand Code so OpenRouter rankings credit the app', () => { + expect(OPENROUTER_ATTRIBUTION_HEADERS).toEqual({ + 'HTTP-Referer': 'https://autohand.ai', + 'X-OpenRouter-Title': 'Autohand Code', + 'X-Title': 'Autohand Code', + 'X-OpenRouter-Categories': 'cli-agent', + }); + }); + + it('sends the documented attribution headers on chat requests', async () => { + const client = new OpenRouterClient({ apiKey: 'test-key', model: 'openrouter/auto' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'resp_headers', + created: 1, + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await client.complete({ messages: [{ role: 'user', content: 'hi' }] } as Parameters[0]); + + const headers = fetchSpy.mock.calls.at(-1)?.[1]?.headers as Record; + expect(headers).toMatchObject(OPENROUTER_ATTRIBUTION_HEADERS); + expect(headers['HTTP-Referer']).toBe('https://autohand.ai'); + expect(headers['X-Title']).toBe('Autohand Code'); + expect(Object.keys(headers).filter((name) => name.startsWith('X-OpenRouter-')).sort()).toEqual(['X-OpenRouter-Categories', 'X-OpenRouter-Title']); + }); +}); diff --git a/tests/search/accountSearchConfig.test.ts b/tests/search/accountSearchConfig.test.ts index b2a94a221..c47ce0d9b 100644 --- a/tests/search/accountSearchConfig.test.ts +++ b/tests/search/accountSearchConfig.test.ts @@ -23,7 +23,8 @@ describe('account-aware search configuration', () => { await configureSearchFromAccount({ auth: { token: 'ahc_old' }, search: { provider: 'autohand' } }); await configureSearchFromAccount({ search: { provider: 'exa', exaApiKey: 'own-key' } }, 'brave'); expect(getSearchConfig().provider).toBe('brave'); - expect(fetch).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1]).toHaveProperty('method', 'GET'); await expect(webSearch('docs', { provider: 'autohand' })).rejects.toThrow('Sign in'); }); it('keeps Free on local providers and reports missing browser setup without a Google fallback', async () => { diff --git a/tests/search/browserProfileLock.test.ts b/tests/search/browserProfileLock.test.ts new file mode 100644 index 000000000..e0a018077 --- /dev/null +++ b/tests/search/browserProfileLock.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { configureSearchFromAccount, isSearchConfigured, webSearch } from '../../src/actions/web.js'; + +const fixture = vi.hoisted(() => ({ home: '', spawn: vi.fn() })); +vi.mock('node:os', async original => ({ ...await original(), homedir: () => fixture.home })); +vi.mock('fs', async original => { + const fs = await original(); + return { ...fs, existsSync: (file: string) => /(?:Google Chrome|google-chrome|chrome\.exe)$/.test(String(file)) || fs.existsSync(file) }; +}); +vi.mock('child_process', async original => ({ ...await original(), spawn: fixture.spawn })); + +let root: string; +beforeEach(async () => { + fixture.home = await mkdtemp(path.join(tmpdir(), 'autohand-profile-lock-')); + root = process.platform === 'darwin' ? path.join(fixture.home, 'Library/Application Support/Google/Chrome') + : process.platform === 'win32' ? path.join(fixture.home, 'Google/Chrome/User Data') : path.join(fixture.home, '.config/google-chrome'); + vi.stubEnv('LOCALAPPDATA', fixture.home); + await mkdir(path.join(root, 'Default'), { recursive: true }); + fixture.spawn.mockReset().mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(), pid: 123 }); + queueMicrotask(() => { + child.stdout.write(''); + child.emit('close', 0); + }); + return child; + }); + await configureSearchFromAccount({ search: { provider: 'browser-profile' } }); +}); +afterEach(async () => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); await rm(fixture.home, { recursive: true, force: true }); }); + +describe('Chrome profile SingletonLock', () => { + it('does not offer a locked profile without a fallback, including Chromium dangling symlink locks', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + expect(isSearchConfigured()).toBe(false); + await expect(webSearch('docs')).rejects.toThrow("Chrome is open on your profile, so browser search can't run. Close Chrome, or pick another provider with /search."); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('uses included Free search instead of launching the locked profile', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + const fetch = vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining: 10, resetAt: '2026-10-27T00:00:00.000Z' } })) + .mockResolvedValueOnce(Response.json({ provider: 'cloudflare', query: 'docs', results: [{ title: 'Managed result', url: 'https://example.org/', snippet: 'Source' }] })); + vi.stubGlobal('fetch', fetch); + await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' }, search: { provider: 'browser-profile' } }); + expect(isSearchConfigured()).toBe(true); + expect(await webSearch('docs')).toHaveProperty('0.title', 'Managed result'); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('uses the connected Chrome bridge before the managed fallback', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + const invoke = vi.fn().mockImplementation(async name => name === 'browser_execute_js' ? JSON.stringify([{ title: 'Bridge result', url: 'https://example.org/', snippet: '' }]) : 'ok'); + expect(await webSearch('docs', { browserToolInvoker: invoke })).toHaveProperty('0.title', 'Bridge result'); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('keeps the hosted tool available at the cap and never launches a locked profile to bypass it', async () => { + await symlink('host-12345', path.join(root, 'SingletonLock')); + const message = "You've used your 10 free web searches today. They reset in 2h 5m. For more, upgrade to Pro: https://dev.autohand.ai/search/upgrade?source=cli"; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'free', provider: 'cloudflare', usage: { limit: 10, remaining: 0, resetAt: '2026-10-27T00:00:00.000Z' } })) + .mockResolvedValueOnce(Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); + await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' }, search: { provider: 'browser-profile' } }); + await expect(webSearch('docs')).rejects.toThrow(message); + expect(isSearchConfigured()).toBe(true); + expect(fixture.spawn).not.toHaveBeenCalled(); + }); + it('still launches the browser profile when Chrome is closed', async () => { + expect(isSearchConfigured()).toBe(true); + expect(await webSearch('docs')).toHaveProperty('0.title', 'Profile result'); + expect(fixture.spawn).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/search/managedWebSearch.test.ts b/tests/search/managedWebSearch.test.ts index b7cef47cd..ff475ad41 100644 --- a/tests/search/managedWebSearch.test.ts +++ b/tests/search/managedWebSearch.test.ts @@ -4,6 +4,11 @@ import { ManagedWebSearch, managedSearchIdentity } from '../../src/search/manage afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); }); describe('included Autohand web search', () => { const identity = { token: 'ahc_search_fixture', accountId: 'selected-account' }; + it('preserves the exact Free cap notice instead of replacing it with generic retry advice', async () => { + const message = "You've used your 10 free web searches today. They reset in 2h 5m. For more, upgrade to Pro: https://dev.autohand.ai/search/upgrade?source=cli"; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); + await expect(new ManagedWebSearch(identity).search('docs', 5)).rejects.toThrow(message); + }); it('uses account credentials and selected account only on the first-party endpoint', async () => { const fetch = vi.fn().mockResolvedValueOnce(Response.json({ available: true, tier: 'lite', provider: 'cloudflare' })) .mockResolvedValueOnce(Response.json({ provider: 'cloudflare', query: 'docs', results: [{ title: 'Docs', url: 'https://autohand.ai/docs', snippet: 'Source' }] })); diff --git a/tests/startup/cliOptions.test.ts b/tests/startup/cliOptions.test.ts index 03233f6bd..67a765530 100644 --- a/tests/startup/cliOptions.test.ts +++ b/tests/startup/cliOptions.test.ts @@ -185,4 +185,29 @@ describe('CLI option normalization', () => { dryRun: true, }); }); + + it('parses string values of --sync-settings as booleans', () => { + for (const value of ['false', 'FALSE', '0', 'no', 'off']) { + const options = { syncSettings: value } as unknown as RootCliOptions; + normalizeInitialCliOptions(options, {}); + expect(options.syncSettings).toBe(false); + } + for (const value of ['true', 'TRUE', '1', 'yes', 'on']) { + const options = { syncSettings: value } as unknown as RootCliOptions; + normalizeInitialCliOptions(options, {}); + expect(options.syncSettings).toBe(true); + } + const bare = { syncSettings: true } as RootCliOptions; + normalizeInitialCliOptions(bare, {}); + expect(bare.syncSettings).toBe(true); + const unset = {} as RootCliOptions; + normalizeInitialCliOptions(unset, {}); + expect(unset.syncSettings).toBeUndefined(); + }); + + it('rejects an unrecognised --sync-settings value instead of silently enabling sync', () => { + const options = { syncSettings: 'maybe' } as unknown as RootCliOptions; + const result = normalizeInitialCliOptions(options, {}); + expect(result.invalidOption).toBe('--sync-settings expects true or false, got "maybe"'); + }); }); diff --git a/tests/startup/workspaceTrustPrompt.test.ts b/tests/startup/workspaceTrustPrompt.test.ts index d2eb3ccc5..4b392b89d 100644 --- a/tests/startup/workspaceTrustPrompt.test.ts +++ b/tests/startup/workspaceTrustPrompt.test.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isWorkspaceTrusted } from '../../src/permissions/workspaceTrust.js'; import { + describeWorkspaceTrustEntries, formatWorkspaceTrustSummary, resolveWorkspaceTrust, } from '../../src/startup/workspaceTrustPrompt.js'; @@ -42,6 +43,7 @@ function untrustedConfig(): LoadedConfig { { name: 'project-tools', transport: 'stdio', command: 'npx', args: ['project-tools-mcp', '--stdio'] }, { name: 'project-docs', transport: 'http', url: 'https://docs.example.test/mcp' }, ], + tools: [], }; return { configPath: path.join(tempDir, 'home', 'config.json'), @@ -142,5 +144,25 @@ describe('formatWorkspaceTrustSummary', () => { expect(summary).toMatch(/pre-prompt\s+node scripts\/check-prompt\.cjs/); expect(summary).toMatch(/project-tools\s+npx project-tools-mcp --stdio/); expect(summary).toMatch(/project-docs\s+https:\/\/docs\.example\.test\/mcp/); + expect(summary).not.toContain('Project tools'); + }); + + it('lists project tool files so a tools-only workspace has something to review', () => { + const trust: WorkspaceTrustState = { + ...untrustedConfig().workspaceTrust!, + hooks: [], + mcpServers: [], + tools: [ + { file: '.autohand/tools/deploy.json', sha256: 'c'.repeat(64) }, + { file: '.autohand/tools/lint.json', sha256: 'd'.repeat(64) }, + ], + }; + + const summary = formatWorkspaceTrustSummary(trust); + + expect(summary).toContain('Project tools: 2 file(s)'); + expect(summary).toContain('.autohand/tools/deploy.json'); + expect(summary).toContain('.autohand/tools/lint.json'); + expect(describeWorkspaceTrustEntries(trust)).toBe('2 project tool files'); }); }); diff --git a/tests/sync/connectorDownload.test.ts b/tests/sync/connectorDownload.test.ts index c6e6a8bf4..b7df6a075 100644 --- a/tests/sync/connectorDownload.test.ts +++ b/tests/sync/connectorDownload.test.ts @@ -56,6 +56,29 @@ describe('Console connector downloads', () => { expect(loaded.mcp?.servers.some((server) => server.managedConnectorId === connector.id)).toBe(true); }); + it('does not publish imported local-only MCP servers to the account', async () => { + const loaded = { + ...config(), + mcp: { + servers: [ + { name: 'imported', transport: 'http' as const, url: 'https://private.example/mcp', headers: { Authorization: 'Bearer secret' }, autoConnect: false, localOnly: true }, + { name: 'shared', transport: 'stdio' as const, command: 'npx', autoConnect: true }, + ], + }, + }; + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'pullConnectors').mockResolvedValue({ revision: 1, connectors: [] }); + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'pullSettingsProfiles').mockResolvedValue([]); + const createConnector = vi.spyOn(CodingAgentControlPlaneClient.prototype, 'createConnector').mockResolvedValue('remote-id'); + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'acknowledgeConnectors').mockResolvedValue(); + vi.spyOn(CodingAgentControlPlaneClient.prototype, 'uploadSettingsSnapshot').mockResolvedValue(); + + await syncCodingAgentControlPlane(loaded, 'test-session', { publishLocalConnectors: true }); + + expect(createConnector).toHaveBeenCalledOnce(); + expect(createConnector).toHaveBeenCalledWith('test-session', expect.objectContaining({ name: 'shared' }), undefined); + expect(JSON.stringify(createConnector.mock.calls)).not.toContain('Bearer secret'); + }); + it('does not mutate live config or acknowledge a snapshot when persistence fails', async () => { const loaded = config(); mocks.saveConfig.mockRejectedValue(new Error('Disk full')); diff --git a/tests/testing/queuedSteeringScenario.test.ts b/tests/testing/queuedSteeringScenario.test.ts new file mode 100644 index 000000000..12fdeafbc --- /dev/null +++ b/tests/testing/queuedSteeringScenario.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from 'tuistory'; +import { pendingQueueSnapshot, steerPendingMessage } from '../../src/testing/scenarios/queuedSteeringScenario.js'; + +afterEach(() => vi.useRealTimers()); + +function repaintingSession(permanentDrift = false) { + const currentRows = 42; + let queued = 0; + let draft = ''; + let steered = false; + let repainting = false; + const frame = (row: number) => { + const lines = Array(currentRows).fill(''); + if (queued) { + lines[31] = `Queue · ${queued} pending · ctrl+enter steer next`; + lines[32] = steered ? '1. QUEUE_LATER: leave this for the following turn' : '1. STEER_NOW: mention the worktrees too'; + if (queued === 2) lines[33] = '2. QUEUE_LATER: leave this for the following turn'; + } + lines[row] = `❯ ${draft}`; + return '\n' + lines.join('\n'); + }; + const text = vi.fn(async () => { + vi.advanceTimersByTime(50); + const intermediate = repainting || (permanentDrift && queued > 0); + repainting = false; + return frame(intermediate ? 4 : 37); + }); + return { + currentRows, + text, + waitIdle: vi.fn(async () => {}), + waitForText: vi.fn(async () => frame(repainting ? 4 : 37)), + getTerminalData: vi.fn((): ReturnType => ({ + cols: 120, rows: currentRows, cursor: [0, 0], cursorVisible: false, cursorStyle: 'block', offset: 0, totalLines: currentRows, + lines: frame(repainting ? 4 : 37).split('\n').slice(-currentRows).map(value => ({ + spans: [{ text: value, fg: null, bg: null, flags: 0, width: value.length }], + })), + })), + type: vi.fn(async value => { draft = value; }), + press: vi.fn(async () => { queued++; draft = ''; repainting = true; }), + writeRaw: vi.fn(() => { queued = 1; steered = true; repainting = true; }), + }; +} + +describe('queued steering terminal assertions', () => { + it('checks queued content and the original composer position in the same complete frame', async () => { + vi.useFakeTimers(); + const session = repaintingSession(); + const screen = await steerPendingMessage(session, { sequence: '\x1b[13;5u', selected: false, fullScreen: true }); + expect(screen.split('\n').slice(-session.currentRows)[37]).toBe('❯ KEEP_THIS_DRAFT'); + expect(pendingQueueSnapshot(screen)).toEqual([ + 'Queue · 1 pending · ctrl+enter steer next', '1. QUEUE_LATER: leave this for the following turn', + ]); + expect(vi.getTimerCount()).toBe(0); + }); + + it('still rejects a composer that remains displaced after queueing', async () => { + vi.useFakeTimers(); + const session = repaintingSession(true); + await expect(steerPendingMessage(session, { sequence: '\x1b[13;5u', selected: false, fullScreen: true })).rejects.toThrow(); + expect(session.press).toHaveBeenCalledOnce(); + expect(session.writeRaw).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/tests/toolsRegistry.spec.ts b/tests/toolsRegistry.spec.ts index 61709be59..dabd39df3 100644 --- a/tests/toolsRegistry.spec.ts +++ b/tests/toolsRegistry.spec.ts @@ -91,7 +91,7 @@ describe('ToolsRegistry', () => { ]); }); - it('loads project-scoped tools before user-scoped tools for future sessions', async () => { + it('lets user-scoped tools win over project-scoped tools across sessions', async () => { const workspaceRoot = path.join(tempRoot, 'workspace'); const userToolsDir = path.join(tempRoot, 'user-tools'); const projectToolsDir = path.join(workspaceRoot, '.autohand', 'tools'); @@ -115,20 +115,24 @@ describe('ToolsRegistry', () => { scope: 'project' }); - const registry = createToolsRegistry(workspaceRoot, userToolsDir); + const registry = createToolsRegistry(workspaceRoot, userToolsDir, { projectToolsTrusted: true }); await registry.initialize(); expect(registry.getMetaTool('shared_tool')).toMatchObject({ - description: 'Project-scoped helper', - scope: 'project' + description: 'User-scoped helper', + scope: 'user' + }); + expect(registry.listMetaTools({ includeDisabled: true }).map((tool) => tool.scope)).toEqual(['user', 'project']); + expect(registry.getDiagnostics()).toContainEqual({ + file: path.join(projectToolsDir, 'shared_tool.json'), + reason: 'project tool shadows user tool "shared_tool"' }); - expect(registry.listMetaTools({ includeDisabled: true }).map((tool) => tool.scope)).toEqual(['project', 'user']); - const nextSessionRegistry = createToolsRegistry(workspaceRoot, userToolsDir); + const nextSessionRegistry = createToolsRegistry(workspaceRoot, userToolsDir, { projectToolsTrusted: true }); await nextSessionRegistry.initialize(); expect(nextSessionRegistry.getMetaTool('shared_tool')).toMatchObject({ - description: 'Project-scoped helper', - scope: 'project' + description: 'User-scoped helper', + scope: 'user' }); }); diff --git a/tests/tuistory/built-cli-research.tuistory.test.ts b/tests/tuistory/built-cli-research.tuistory.test.ts index 38d320906..e79fbfec4 100644 --- a/tests/tuistory/built-cli-research.tuistory.test.ts +++ b/tests/tuistory/built-cli-research.tuistory.test.ts @@ -828,7 +828,7 @@ describe('interactive built CLI Tuistory tests: processes, research, usage, sett }); it('persists an Anthropic provider selection and restores it after restart', async () => { - const selectedModel = 'claude-sonnet-5'; + const selectedModel = 'claude-sonnet-5-5'; const apiKey = 'sk-ant-tuistory-key-long-enough'; const authServer = await createMockAuthServer(); mockAuthServers.push(authServer); @@ -863,7 +863,7 @@ describe('interactive built CLI Tuistory tests: processes, research, usage, sett await session.type(apiKey); await session.press('enter'); await session.waitForText('Select a model', { timeout: 10_000 }); - await selectModalOptionByLabel(session, 'Claude Sonnet 5'); + await selectModalOptionByLabel(session, 'Claude Sonnet 5.5'); await session.waitForText('Anthropic configured successfully!', { timeout: 10_000 }); const screen = await session.text({ timeout: 10_000, diff --git a/tests/tuistory/claude-permission-import.tuistory.test.ts b/tests/tuistory/claude-permission-import.tuistory.test.ts new file mode 100644 index 000000000..dea35eb93 --- /dev/null +++ b/tests/tuistory/claude-permission-import.tuistory.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { createTempAutohandHome, launchBuiltAutohand, waitForExit, expectCleanExit } from './helpers/autohandTuistory.js'; + +describe('built Claude permission import', () => { + it('previews mappings without writes, then imports and reports legacy cleanup in the terminal', async () => { + const state = await createTempAutohandHome(); + const source = path.join(state.workspaceRoot, 'claude-fixture'); + const legacy = path.join(state.autohandHome, 'imported-claude-settings.json'); + await fs.outputJson(path.join(source, 'settings.json'), { env: { TOKEN: 'NEVER_COPY_ENV' }, permissions: { allow: ['Bash(npm test:*)', 'Read(./src/**)'], deny: ['Bash(rm:*)', 'UnknownTool(example)'] } }); + await fs.outputJson(legacy, { raw: { env: { TOKEN: 'SCRUB_LEGACY_ENV' } } }); + const beforeConfig = await fs.readFile(state.configPath, 'utf8'); + const beforeLegacy = await fs.readFile(legacy, 'utf8'); + const projectFile = path.join(state.workspaceRoot, '.autohand', 'settings.local.json'); + const hadProjectSettings = await fs.pathExists(projectFile); + await fs.outputJson(path.join(state.workspaceRoot, '.mcp.json'), { mcpServers: { + 'private-fixture': { command: 'fixture-server', env: { TOKEN: 'MCP_TERMINAL_FIXTURE_SECRET' } }, + } }); + const args = ['--path', state.workspaceRoot, '--config', state.configPath, 'import', 'claude', '--categories', 'settings,mcp']; + try { + const preview = await launchBuiltAutohand([...args, '--dry-run'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { CLAUDE_CONFIG_DIR: source }, rows: 48 }); + try { + await waitForExit(preview, 30_000); + expectCleanExit(preview); + const text = await preview.text({ immediate: true }); + expect(text).toContain('Would translate 2 allow and 1 deny'); + expect(text).toContain('run_command:npm test*'); + expect(text).toContain('skipped (no equivalent)'); + expect(text).not.toContain('NEVER_COPY_ENV'); + expect(await fs.readFile(state.configPath, 'utf8')).toBe(beforeConfig); + expect(await fs.readFile(legacy, 'utf8')).toBe(beforeLegacy); + expect(await fs.pathExists(projectFile)).toBe(hadProjectSettings); + } finally { preview.close(); } + const imported = await launchBuiltAutohand(args, { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { CLAUDE_CONFIG_DIR: source }, rows: 48 }); + try { + await waitForExit(imported, 30_000); + expectCleanExit(imported); + const text = await imported.text({ immediate: true }); + expect(text).toContain('Translated 2 allow and 1 deny'); + expect(text).toContain('Scrubbed raw Claude settings'); + expect(text).not.toMatch(/NEVER_COPY_ENV|SCRUB_LEGACY_ENV|MCP_TERMINAL_FIXTURE_SECRET/); + expect((await fs.readJson(state.configPath)).permissions.denyList).toContain('run_command:rm*'); + expect(await fs.readJson(legacy)).not.toHaveProperty('raw'); + expect((await fs.readJson(projectFile)).mcp.servers).toContainEqual(expect.objectContaining({ + name: 'private-fixture', autoConnect: false, localOnly: true, + })); + } finally { imported.close(); } + } finally { await state.cleanup(); } + }); +}); diff --git a/tests/tuistory/cli-model-availability.tuistory.test.ts b/tests/tuistory/cli-model-availability.tuistory.test.ts new file mode 100644 index 000000000..55b16378d --- /dev/null +++ b/tests/tuistory/cli-model-availability.tuistory.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest'; +import { createTempAutohandHome, launchBuiltAutohand } from './helpers/autohandTuistory.js'; + +it('explains the API, Agent SDK and Console routes when Weka is selected explicitly', async () => { + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', features: { autohand_inference: true }, + autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture-only', model: 'fantail' }, + } }); + const session = await launchBuiltAutohand(['--model', 'weka', '--config', state.configPath], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + try { + await session.waitForText('Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI.', { timeout: 20_000 }); + expect(session.readAll().replace(/\s+/g, ' ')).toContain('Weka is available through the Autohand API, the Agent SDKs and the Console, not in the CLI. Pick another model with /model or --model.'); + } finally { + session.close(); + await state.cleanup(); + } +}); diff --git a/tests/tuistory/cloud-cli.tuistory.test.ts b/tests/tuistory/cloud-cli.tuistory.test.ts index 7310f4ca7..f3fbc7d4e 100644 --- a/tests/tuistory/cloud-cli.tuistory.test.ts +++ b/tests/tuistory/cloud-cli.tuistory.test.ts @@ -2,7 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; -import { seedCloudScenario, cloudScenarioId, cloudViewport } from '../../src/testing/scenarios/cloudScenario.js'; +import { seedCloudScenario, cloudScenarioId, cloudViewport, startCloudWithoutRepository } from '../../src/testing/scenarios/cloudScenario.js'; import { createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit, type TuistoryTempState } from './helpers/autohandTuistory.js'; const sessions: Session[] = [], states: TuistoryTempState[] = []; @@ -108,9 +108,17 @@ describe('built CLI cloud terminal flow', () => { const calls = await requests(state); expect(calls.filter(call => call.method === 'POST').map(call => call.route)).toEqual([`conversations/${cloudScenarioId}/stop`]); }); - it('refuses an unsupported plan before provisioning or sending a task', async () => { - const { state, session } = await launch(['--prompt', 'Repair parser'], 'lite'); await waitForExit(session); - expect(await session.readAll()).toContain('Pro, Pro+, and Max'); + it.each(['team', 'enterprise'])('starts a %s cloud session from the terminal', async tier => { + const { state, session } = await launch(['--prompt', 'list files'], tier); + await startCloudWithoutRepository(session); + const calls = await requests(state); + expect(calls.find(call => call.route.endsWith('/messages'))?.body).toMatchObject({ content: 'list files' }); + await session.press(['ctrl', 'c']); await waitForExit(session); + expect(session.exitInfo?.exitCode).toBe(0); + }); + it.each(['free', 'lite'])('refuses %s before provisioning or sending a task', async tier => { + const { state, session } = await launch(['--prompt', 'Repair parser'], tier); await waitForExit(session); + expect(await session.readAll()).toContain('Cloud CLI sessions need Pro, Pro+, Max, Team or Enterprise. Upgrade in Console.'); expect((await requests(state)).every(call => call.method === 'GET')).toBe(true); expect(session.exitInfo?.exitCode).not.toBe(0); }); diff --git a/tests/tuistory/free-search.tuistory.test.ts b/tests/tuistory/free-search.tuistory.test.ts new file mode 100644 index 000000000..c60e01a96 --- /dev/null +++ b/tests/tuistory/free-search.tuistory.test.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { inspectFreeSearchAllowance } from '../../src/testing/scenarios/freeSearchScenario.js'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; + +describe('Free web search allowance in the built terminal', () => { + it.each([7, 0])('shows %s searches left, supports cancel, and exits cleanly', async remaining => { + const state = await createTempAutohandHome({ config: { + provider: 'openrouter', openrouter: { apiKey: 'fixture', model: 'fixture' }, auth: { token: 'ahc_verified_free' }, + search: { provider: 'browser-profile' }, agent: { autoMemory: false }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + let session: Awaited> | undefined; + try { + const preload = fileURLToPath(new URL('../../src/testing/fixtures/free-search-fetch.mjs', import.meta.url)); + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 140, rows: 45, + env: { NODE_OPTIONS: `--import=${preload}`, AUTOHAND_TEST_SEARCH_REMAINING: String(remaining) }, + }); + await inspectFreeSearchAllowance(session, remaining); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + } finally { + session?.close(); await session?.waitForExit(5000); await state.cleanup(); + } + }); +}); diff --git a/tests/tuistory/plan-review.tuistory.test.ts b/tests/tuistory/plan-review.tuistory.test.ts index bab70f8b7..6e854d31c 100644 --- a/tests/tuistory/plan-review.tuistory.test.ts +++ b/tests/tuistory/plan-review.tuistory.test.ts @@ -30,11 +30,12 @@ afterEach(async () => { for (const state of states.splice(0)) await state.cleanup(); }); -async function launch() { +async function launch(executeEdits = false, unrestricted = false) { const server = await createMockAutohandAINativeSequenceServer([ { content: 'Drafting a plan.', toolCall: { id: 'first-plan', name: 'plan', args: { notes: FIRST_PLAN_NOTES } } }, { content: 'Refining the plan.', toolCall: { id: 'refined-plan', name: 'plan', args: { notes: REFINED_PLAN_NOTES } } }, { content: 'Presenting the plan.', toolCall: { id: 'review-plan', name: 'exit_plan_mode', args: {} } }, + ...(executeEdits ? [{ content: 'Implementing the accepted plan.', toolCall: { id: 'approved-edit', name: 'write_file', args: { path: 'approved-plan.txt', contents: 'accepted plan edit' } } }] : []), { content: 'PLAN_REVIEW_COMPLETE' }, ]); servers.push(server); @@ -42,6 +43,7 @@ async function launch() { provider: 'autohandai', autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'tuistory-test-key', model: 'moa', baseUrl: server.baseUrl }, features: { autohand_inference: true }, + permissions: { mode: unrestricted ? 'unrestricted' : 'interactive' }, agent: { autoMemory: false, maxIterations: 6, sessionRetryLimit: 0 }, network: { maxRetries: 0, retryDelay: 0 }, ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, @@ -78,6 +80,32 @@ function toolResult(server: MockNativeToolServer, toolCallId: string): string | } describe('plan review', () => { + it('auto-accepts the chosen plan edit and restores the normal composer afterward', async () => { + const { session, state } = await launch(true); + await planUntilReview(session); + await session.type('3'); + await session.waitForText('PLAN_REVIEW_COMPLETE', { timeout: 30_000 }); + expect(await readFile(path.join(state.workspaceRoot, 'approved-plan.txt'), 'utf8')).toBe('accepted plan edit'); + await session.waitIdle(); + const current = session.getTerminalData(); + const visible = current.lines.slice(-current.rows).map(line => line.spans.map(span => span.text).join('')).join('\n'); + expect(visible.split('\n').slice(-5).join('\n')).not.toContain('[EXEC]'); + await exitInteractive(session); + }); + + it('prompts for a manual plan edit even when the session started unrestricted', async () => { + const { session, state } = await launch(true, true); + await planUntilReview(session); + await session.type('2'); + await session.waitForText('Allow Once', { timeout: 20_000 }); + await expect(readFile(path.join(state.workspaceRoot, 'approved-plan.txt'), 'utf8')).rejects.toThrow(); + await session.press('down'); + await session.press('enter'); + await session.waitForText('PLAN_REVIEW_COMPLETE', { timeout: 30_000 }); + await expect(readFile(path.join(state.workspaceRoot, 'approved-plan.txt'), 'utf8')).rejects.toThrow(); + await exitInteractive(session); + }); + it('shows the plan as written, goes straight to review after a refinement, and says where the file is', async () => { const { session, state } = await launch(); diff --git a/tests/tuistory/program-status.tuistory.test.ts b/tests/tuistory/program-status.tuistory.test.ts index 910713c3c..217be9a6d 100644 --- a/tests/tuistory/program-status.tuistory.test.ts +++ b/tests/tuistory/program-status.tuistory.test.ts @@ -52,8 +52,12 @@ describe('OSC7501 built terminal lifecycle', () => { ]); await submitProgramStatusPrompt(session, 'Write the proof file'); await vi.waitFor(() => expect(programStatusReports(session)).toContainEqual(expect.objectContaining({ state: 'blocked', kind: 'permission' })), { timeout: 15_000 }); + // The blocked report precedes the modal mounting its keyboard listener. + await session.text({ waitFor: text => /▸\s+1\. Yes/u.test(text) }); await session.press('down'); + await session.text({ waitFor: text => /▸\s+2\. No/u.test(text) }); await session.press('up'); + await session.text({ waitFor: text => /▸\s+1\. Yes/u.test(text) }); await session.press('enter'); await session.waitForText('PERMISSION_STATUS_COMPLETE'); await vi.waitFor(() => expect(programStatusReports(session).at(-1)?.state).toBe('done')); diff --git a/tests/tuistory/queued-steering-recovery.tuistory.test.ts b/tests/tuistory/queued-steering-recovery.tuistory.test.ts index cb933273c..093adda96 100644 --- a/tests/tuistory/queued-steering-recovery.tuistory.test.ts +++ b/tests/tuistory/queued-steering-recovery.tuistory.test.ts @@ -119,7 +119,7 @@ describe('queued steering and streamed tool-call recovery in the built CLI', () await vi.waitFor(() => expect(server.requests).toHaveLength(1), { timeout: 15_000 }); const screen = await steerPendingMessage(session, options); if (options.fullScreen) { - const lines = (await session.text()).split('\n'); + const lines = screen.split('\n').slice(-session.currentRows); const composerRow = lines.findLastIndex(line => line.trimStart().startsWith('❯')); expect(composerRow).toBeGreaterThanOrEqual(session.currentRows - 7); expect(composerRow).toBeLessThan(session.currentRows); diff --git a/tests/tuistory/team-activity.tuistory.test.ts b/tests/tuistory/team-activity.tuistory.test.ts index 671346d18..65a24780b 100644 --- a/tests/tuistory/team-activity.tuistory.test.ts +++ b/tests/tuistory/team-activity.tuistory.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createServer, type Server } from 'node:http'; import type { Session } from 'tuistory'; -import { inspectTerminalTeamOutcomes } from '../../src/testing/scenarios/teamTerminalStatusScenario.js'; +import { inspectTerminalTeamOutcomes, recoverFailedTeam } from '../../src/testing/scenarios/teamTerminalStatusScenario.js'; import { createTempAutohandHome, exitInteractive, @@ -19,8 +19,9 @@ const sessions: Session[] = []; const tempStates: TuistoryTempState[] = []; const servers: Server[] = []; -async function createTeamSequenceServer(failTeammate = false): Promise { +async function createTeamSequenceServer(failTeammate: boolean | 'once' = false): Promise { let leadTurn = 0; + let teammateFailures = 0; const server = createServer((request, response) => { const chunks: Buffer[] = []; request.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -41,6 +42,8 @@ async function createTeamSequenceServer(failTeammate = false): Promise { let content: string; let delayMs = 0; + const rejectTeammate = isSecurityTeammate && Boolean(failTeammate) && (failTeammate !== 'once' || teammateFailures === 0); + if (rejectTeammate) teammateFailures++; if (isSecurityTeammate) { content = JSON.stringify({ finalResponse: 'SECURITY_TASK_DONE', toolCalls: [] }); delayMs = 5_000; @@ -100,7 +103,7 @@ async function createTeamSequenceServer(failTeammate = false): Promise { } setTimeout(() => { - if (failTeammate && isSecurityTeammate) { + if (rejectTeammate) { response.writeHead(400, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: { message: 'SECURITY_TASK_FAILED', type: 'invalid_request_error' } })); return; @@ -164,7 +167,7 @@ describe('interactive team activity', () => { const activeScreen = await session.text({ timeout: 60_000, waitFor: (text) => text.includes('team-e2e · 0/1 done') - && text.includes('cmd+t team') + && text.includes('ctrl+t team') && text.includes('AUTO'), }); expect(activeScreen).toContain('Review authentication'); @@ -188,14 +191,15 @@ describe('interactive team activity', () => { await session.text({ timeout: 45_000, - waitFor: (text) => text.includes('team-e2e · 1/1 done') + waitFor: (text) => text.includes('1 succeeded') && text.includes('Review authentication'), }); + await session.press(['ctrl', 't']); await session.type('/tasks'); await session.press('enter'); const completedScreen = await session.text({ timeout: 10_000, - waitFor: (text) => text.includes('Tasks [1/1 done]'), + waitFor: (text) => /Tasks\s+1\/1 done/u.test(text), }); expect(completedScreen).toContain('Review authentication'); @@ -236,4 +240,26 @@ describe('interactive team activity', () => { await exitInteractive(session); }, 90_000); + + it('recovers a failed task, stops the native workers, and starts a new team', async () => { + const baseUrl = await createTeamSequenceServer('once'); + const state = await createTempAutohandHome({ config: { + openrouter: { baseUrl, model: 'openai/gpt-4o-mini' }, + agent: { maxIterations: 8, sessionRetryLimit: 0 }, network: { maxRetries: 0 }, + ui: { promptSuggestions: false }, features: { automaticSpecialists: false }, + } }); + tempStates.push(state); + const session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, rows: 40, waitForDataTimeout: 15_000, + }); + sessions.push(session); + await inspectTerminalTeamOutcomes(session); + const recovery = await recoverFailedTeam(session); + expect(recovery.failure).toContain('SECURITY_TASK_FAILED'); + expect(recovery.retried).toContain('1 succeeded'); + expect(recovery.retried).toContain('1 cancelled'); + expect(recovery.closed).toContain('has been shut down'); + expect(recovery.replacement).toContain('Team "replacement" created'); + await exitInteractive(session); + }, 120_000); }); diff --git a/tests/tuistory/worktree-cleanup.tuistory.test.ts b/tests/tuistory/worktree-cleanup.tuistory.test.ts new file mode 100644 index 000000000..1833c7ef9 --- /dev/null +++ b/tests/tuistory/worktree-cleanup.tuistory.test.ts @@ -0,0 +1,114 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { Session } from 'tuistory'; +import { + createMockAutohandAINativeSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit, + type MockNativeToolServer, type TuistoryTempState, +} from './helpers/autohandTuistory.js'; + +const sessions: Session[] = []; +const states: TuistoryTempState[] = []; +const servers: MockNativeToolServer[] = []; +afterEach(async () => { + for (const session of sessions.splice(0)) session.close(); + for (const server of servers.splice(0)) await server.close(); + for (const state of states.splice(0)) await state.cleanup(); +}); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +async function launch(tmux = false) { + const state = await createTempAutohandHome({ config: { + agent: { autoMemory: false }, ui: { promptSuggestions: false }, + } }); + states.push(state); + git(state.workspaceRoot, 'config', 'user.name', 'Worktree Tests'); + git(state.workspaceRoot, 'config', 'user.email', 'tests@autohand.ai'); + await writeFile(path.join(state.workspaceRoot, '.gitignore'), '.autohand/\n'); + git(state.workspaceRoot, 'add', '.'); + git(state.workspaceRoot, 'commit', '-qm', 'Initial fixture'); + const session = await launchBuiltAutohand(['--worktree', 'cleanup-fixture', '--config', state.configPath, '--offline'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + env: { AUTOHAND_TMUX_LAUNCHED: tmux ? '1' : undefined }, + }); + sessions.push(session); + await session.waitForText('❯'); + const worktreePath = `${state.workspaceRoot}-cleanup-fixture`; + expect(existsSync(worktreePath)).toBe(true); + return { state, session, worktreePath }; +} + +describe('--worktree exit cleanup', () => { + it('removes the sibling directory, registration and new branch after a clean interactive exit', async () => { + const { state, session, worktreePath } = await launch(); + await exitInteractive(session); + expect(existsSync(worktreePath), session.readAll()).toBe(false); + expect(git(state.workspaceRoot, 'worktree', 'list', '--porcelain')).not.toContain(worktreePath); + expect(git(state.workspaceRoot, 'branch', '--list', 'cleanup-fixture')).toBe(''); + expect(session.exitInfo?.exitCode).toBe(0); + }); + + it.each(['untracked', 'committed'])('keeps %s work and prints its recovery path and branch', async kind => { + const { state, session, worktreePath } = await launch(); + const file = path.join(worktreePath, 'valuable.txt'); + await writeFile(file, 'session work\n'); + if (kind === 'committed') { + git(worktreePath, 'add', '.'); + git(worktreePath, 'commit', '-qm', 'Preserve session work'); + } + await exitInteractive(session); + expect(await readFile(file, 'utf8')).toBe('session work\n'); + expect(session.readAll()).toContain('Kept worktree'); + expect(session.readAll()).toContain('(branch cleanup-fixture) with changes'); + expect(session.readAll()).toContain(worktreePath); + expect(git(state.workspaceRoot, 'branch', '--list', 'cleanup-fixture')).toContain('cleanup-fixture'); + }); + + it('preserves the existing tmux-child worktree behavior', async () => { + const { session, worktreePath } = await launch(true); + await exitInteractive(session); + expect(existsSync(worktreePath)).toBe(true); + expect(session.readAll()).not.toContain('Removed worktree'); + }); + + it.each([ + { patch: false, tmux: false }, + { patch: true, tmux: false }, + { patch: true, tmux: true }, + ])('cleans up only non-tmux command worktrees (patch=$patch, tmux=$tmux)', async ({ patch, tmux }) => { + const server = await createMockAutohandAINativeSequenceServer([{ content: 'WORKTREE_COMMAND_DONE' }]); + servers.push(server); + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', + autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'tuistory-key', model: 'moa', baseUrl: server.baseUrl }, + features: { autohand_inference: true, automaticSpecialists: false }, + agent: { autoMemory: false, maxIterations: 4, sessionRetryLimit: 0 }, + network: { maxRetries: 0, retryDelay: 0 }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + states.push(state); + git(state.workspaceRoot, 'config', 'user.name', 'Worktree Tests'); + git(state.workspaceRoot, 'config', 'user.email', 'tests@autohand.ai'); + await writeFile(path.join(state.workspaceRoot, '.gitignore'), '.autohand/\n'); + git(state.workspaceRoot, 'add', '.'); + git(state.workspaceRoot, 'commit', '-qm', 'Initial fixture'); + const session = await launchBuiltAutohand([ + '--worktree', 'cleanup-fixture', '--config', state.configPath, '--offline', '--yes', '-p', 'Respond without editing files.', + ...(patch ? ['--patch'] : []), + ], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, + env: { AUTOHAND_TMUX_LAUNCHED: tmux ? '1' : undefined } }); + sessions.push(session); + await waitForExit(session, 30_000); + expect(session.exitInfo?.exitCode, session.readAll()).toBe(0); + expect(server.requests.length).toBeGreaterThan(0); + expect(existsSync(`${state.workspaceRoot}-cleanup-fixture`), session.readAll()).toBe(tmux); + const branches = git(state.workspaceRoot, 'branch', '--list', 'cleanup-fixture'); + if (tmux) expect(branches).toContain('cleanup-fixture'); + else expect(branches).toBe(''); + }); +}); diff --git a/tests/ui/ink/ShortcutsHelpPanel.test.tsx b/tests/ui/ink/ShortcutsHelpPanel.test.tsx index a7f302e50..f754c07ef 100644 --- a/tests/ui/ink/ShortcutsHelpPanel.test.tsx +++ b/tests/ui/ink/ShortcutsHelpPanel.test.tsx @@ -9,6 +9,11 @@ import { resolveKeybindings } from '../../../src/keybindings/profiles.js'; afterEach(cleanup); describe('shortcut help', () => { + it('includes the team and goal panel shortcuts', () => { + const view = render(); + expect(view.lastFrame()).toContain('Team panel'); + expect(view.lastFrame()).toContain('Goals panel'); + }); it.each([false, true])('shows the fixed queue steering shortcut with fullScreen=%s', async fullScreen => { const view = render(); await new Promise(resolve => setImmediate(resolve)); diff --git a/tests/ui/ink/StatusLine.test.tsx b/tests/ui/ink/StatusLine.test.tsx index 17dfba354..ba9a74f89 100644 --- a/tests/ui/ink/StatusLine.test.tsx +++ b/tests/ui/ink/StatusLine.test.tsx @@ -9,7 +9,7 @@ import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { StatusLine, WORKING_SPINNER_INTERVAL_MS, formatLineSegments, mergeLineExtensions } from '../../../src/ui/ink/StatusLine.js'; +import { StatusLine, WORKING_SPINNER_INTERVAL_MS, buildTeamActivitySegment, formatLineSegments, mergeLineExtensions } from '../../../src/ui/ink/StatusLine.js'; import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; import { I18nProvider } from '../../../src/ui/i18n/index.js'; @@ -24,6 +24,33 @@ function renderStatusLine(props: React.ComponentProps) { } describe('StatusLine extensions', () => { + it('settles a failed team batch without leaving a spinner timer running', async () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + try { + const teamActivity = { + team: { name: 'recovery', createdAt: '', leadSessionId: 'lead', status: 'active' as const, + members: [{ name: 'reviewer', agentName: 'reviewer', pid: 123, status: 'idle' as const }] }, + tasks: ['completed', 'failed', 'failed', 'cancelled'].map((status, index) => ({ + id: String(index), subject: 'Review', description: '', blockedBy: [], createdAt: '', + status: status as 'completed' | 'failed' | 'cancelled', + })), + }; + expect(buildTeamActivitySegment(teamActivity).busy).toBe(false); + const screen = renderStatusLine({ isWorking: false, status: '', teamActivity }); + await vi.advanceTimersByTimeAsync(WORKING_SPINNER_INTERVAL_MS * 2); + expect(screen.lastFrame()).toContain('Finished with failures'); + expect(screen.lastFrame()).toContain('1 succeeded'); + expect(screen.lastFrame()).toContain('2 failed'); + expect(screen.lastFrame()).toContain('1 cancelled'); + expect(screen.lastFrame()).toContain('ctrl+t'); + expect(vi.getTimerCount()).toBe(baseline); + screen.unmount(); + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(baseline)); + } finally { + vi.useRealTimers(); + } + }); it('reports each spinner frame while working so the terminal tab can follow it', async () => { vi.useFakeTimers(); try { @@ -107,7 +134,7 @@ describe('StatusLine extensions', () => { expect(frame).toContain('prompt-shrink'); expect(frame).toContain('1/3 done'); expect(frame).toContain('1 working'); - expect(frame).toContain('cmd+t'); + expect(frame).toContain('ctrl+t'); }); it('appends custom status segments after default active-turn chrome', () => { diff --git a/tests/ui/ink/TeamPanel.test.tsx b/tests/ui/ink/TeamPanel.test.tsx index edea241c5..4728f85bf 100644 --- a/tests/ui/ink/TeamPanel.test.tsx +++ b/tests/ui/ink/TeamPanel.test.tsx @@ -4,16 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { stripVTControlCharacters } from 'node:util'; -import { render } from 'ink-testing-library'; +import { cleanup, render } from 'ink-testing-library'; import { TeamPanel } from '../../../src/ui/ink/TeamPanel.js'; import { TeammateView } from '../../../src/ui/ink/TeammateView.js'; import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; import { Theme } from '../../../src/ui/theme/Theme.js'; import { loadTheme } from '../../../src/ui/theme/loader.js'; import type { Team, TeamTask } from '../../../src/core/teams/types.js'; +import { AgentUI, createInitialUIState } from '../../../src/ui/ink/AgentUI.js'; +import { I18nProvider } from '../../../src/ui/i18n/index.js'; +import { resolveKeybindings } from '../../../src/keybindings/profiles.js'; + +afterEach(cleanup); const mockTeam: Team = { name: 'code-cleanup', @@ -37,11 +42,73 @@ function renderWithTheme(element: React.ReactElement) { } describe('TeamPanel', () => { + it('shows and handles the configured team shortcut', async () => { + const onHide = vi.fn(); + const view = renderWithTheme(); + expect(view.lastFrame()).toContain('ctrl+y hide'); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('\u0019'); + await vi.waitFor(() => expect(onHide).toHaveBeenCalledOnce()); + }); + it('owns recovery keys while the lead is working without sending them as a new prompt', async () => { + const onInstruction = vi.fn(); + const onToggleTeamPanel = vi.fn(); + const view = render( {}} onCtrlC={() => {}} />); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('s'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Shut down this team')); + view.stdin.write('y'); + await vi.waitFor(() => expect(onInstruction).toHaveBeenCalledExactlyOnceWith('/team shutdown')); + expect(onToggleTeamPanel).toHaveBeenCalledOnce(); + }); + it.each(['none', 'truecolor'] as const)('inspects a failure and confirms a selected retry without rerunning successful work in %s mode', async colorMode => { + const onCommand = vi.fn(); + const onHide = vi.fn(); + const theme = new Theme('dark', loadTheme('dark').colors, colorMode); + const view = render(); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('\u001b[B'); + await vi.waitFor(() => expect(stripVTControlCharacters(view.lastFrame() ?? '')).toContain('› × Write API docs')); + view.stdin.write('\r'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Provider unavailable')); + expect(view.lastFrame()).toContain('Partial findings retained'); + view.stdin.write('\u001b'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Enter inspect')); + view.stdin.write('r'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Retry')); + expect(onCommand).not.toHaveBeenCalled(); + view.stdin.write('y'); + await vi.waitFor(() => expect(onCommand).toHaveBeenCalledWith('/team retry task-002')); + expect(onHide).toHaveBeenCalledOnce(); + }); + + it('distinguishes hiding from shutting down and permits cancellation of shutdown', async () => { + const onCommand = vi.fn(); + const onHide = vi.fn(); + const view = renderWithTheme(); + await new Promise(resolve => setImmediate(resolve)); + view.stdin.write('s'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Shut down')); + view.stdin.write('n'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Enter inspect')); + expect(onCommand).not.toHaveBeenCalled(); + view.stdin.write('h'); + await vi.waitFor(() => expect(onHide).toHaveBeenCalledOnce()); + expect(onCommand).not.toHaveBeenCalled(); + }); it('should render team name and status', () => { const { lastFrame } = renderWithTheme(); const output = lastFrame(); expect(output).toContain('code-cleanup'); - expect(output).toContain('Cmd+T close'); + expect(output).toContain('ctrl+t hide'); }); it('should render task count', () => { diff --git a/tests/ui/ink/WorkspaceTrustPrompt.test.tsx b/tests/ui/ink/WorkspaceTrustPrompt.test.tsx index f3ff72cf4..43cc16962 100644 --- a/tests/ui/ink/WorkspaceTrustPrompt.test.tsx +++ b/tests/ui/ink/WorkspaceTrustPrompt.test.tsx @@ -19,6 +19,7 @@ const trust: WorkspaceTrustState = { trusted: false, hooks: [{ event: 'session-start', command: 'node scripts/record.cjs start' }], mcpServers: [{ name: 'project-tools', transport: 'stdio', command: 'npx', args: ['project-tools-mcp'] }], + tools: [], }; function renderPrompt() { diff --git a/tests/unixInstallerPlatforms.test.ts b/tests/unixInstallerPlatforms.test.ts index 5bac90535..1c4d33ec7 100644 --- a/tests/unixInstallerPlatforms.test.ts +++ b/tests/unixInstallerPlatforms.test.ts @@ -9,24 +9,22 @@ const functions = installer.replace(/\nmain "\$@" \|\| exit 1\s*$/, ''); const alpineHost = await fs.pathExists('/etc/alpine-release'); describe.skipIf(process.platform === 'win32')('Unix installer platform compatibility', () => { - async function run(system: string, libc: string, fullInstall = false) { + async function run(system: string, libc: string, fullInstall = false, args: string[] = [], windowsExit = 0) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'installer-platform-')); try { - const bin = path.join(root, 'bin'); - await fs.ensureDir(bin); const commands = { uname: `case "$1" in -s) printf '%s\\n' '${system}' ;; -m) printf 'x86_64\\n' ;; esac`, ldd: `printf '%s\\n' '${libc}' >&2`, - curl: 'exit 22', + curl: `case "$*" in *install.ps1*) while [ "$#" -gt 0 ]; do if [ "$1" = '-o' ]; then shift; printf '# fixture\\n' > "$1"; return 0; fi; shift; done ;; esac; return 22`, + powershell: `printf '<%s>\\n' "$@"; return ${windowsExit}`, + cygpath: `shift; printf '%s\\n' "$1"`, }; - for (const [name, body] of Object.entries(commands)) { - await fs.writeFile(path.join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); - } + const fixtures = Object.entries(commands).map(([name, body]) => `${name}() {\n${body}\n}`).join('\n'); const script = path.join(root, 'probe.sh'); - await fs.writeFile(script, fullInstall ? installer : `${functions}\nget_architecture\nprintf '%s\\n' "$RETVAL"\n`); - return spawnSync('/bin/sh', [script], { - encoding: 'utf8', timeout: 5_000, - env: { ...process.env, PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, AUTOHAND_VERSION: '0.9.8', + await fs.writeFile(script, `${fixtures}\n${fullInstall ? installer : `${functions}\nget_architecture\nprintf '%s\\n' "$RETVAL"\n`}`); + return spawnSync('/bin/sh', [script, ...args], { + encoding: 'utf8', timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', AUTOHAND_VERSION: '0.9.8', AUTOHAND_INSTALL_DIR: path.join(root, 'install'), AUTOHAND_INSTALL_FIRST_RUN: 'no', AUTOHAND_SKIP_ZIT_INSTALL: '1' }, }); } finally { @@ -34,6 +32,24 @@ describe.skipIf(process.platform === 'win32')('Unix installer platform compatibi } } + it.each(['MINGW64_NT-10.0', 'MSYS_NT-10.0', 'CYGWIN_NT-10.0'])( + 'hands %s installation to PowerShell with the original options', async system => { + const result = await run(system, '', true, ['--alpha', '--fresh', '--no-zit']); + expect(result.status, result.stdout + result.stderr).toBe(0); + for (const option of ['<-File>', '<-Alpha>', '<-Fresh>', '<-NoZit>', '<-Version>', '<0.9.8>', '<-InstallDir>']) { + expect(result.stdout).toContain(option); + } + expect(result.stdout + result.stderr).not.toContain('autohand-windows-x64.tar.gz'); + }, + ); + + it('propagates failure from the Windows installer', async () => { + const result = await run('MINGW64_NT-10.0', '', true, [], 7); + expect(result.status).not.toBe(0); + expect(result.stdout).toContain('<-File>'); + expect(result.stdout).not.toContain('Autohand CLI is ready'); + }); + it('selects a musl-specific Linux asset', async () => { const result = await run('Linux', 'musl libc 1.2.5'); expect(result.status, result.stderr).toBe(0); diff --git a/tests/utils/sessionWorktree.spec.ts b/tests/utils/sessionWorktree.spec.ts index 169e8d151..3b1609284 100644 --- a/tests/utils/sessionWorktree.spec.ts +++ b/tests/utils/sessionWorktree.spec.ts @@ -30,6 +30,7 @@ describe('prepareSessionWorktree', () => { it('creates a new worktree and branch for auto-named sessions', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -42,9 +43,9 @@ describe('prepareSessionWorktree', () => { expect(result.repoRoot).toBe('/tmp/repo'); expect(result.createdBranch).toBe(true); expect(result.branchName).toMatch(/^autohand-cli-/); - expect(mockSpawnSync).toHaveBeenCalledTimes(3); + expect(mockSpawnSync).toHaveBeenCalledTimes(4); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs[0]).toBe('worktree'); expect(addArgs[1]).toBe('add'); expect(addArgs[2]).toBe('-b'); @@ -55,6 +56,7 @@ describe('prepareSessionWorktree', () => { it('uses an existing branch when the requested branch already exists', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/workspace/proj\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -67,7 +69,7 @@ describe('prepareSessionWorktree', () => { expect(result.branchName).toBe('feature-fix-42'); expect(result.createdBranch).toBe(false); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs).toEqual([ 'worktree', 'add', @@ -81,6 +83,7 @@ describe('prepareSessionWorktree', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -92,7 +95,7 @@ describe('prepareSessionWorktree', () => { expect(result.worktreePath).toBe('/tmp/repo-feature-2'); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs).toEqual([ 'worktree', 'add', @@ -120,6 +123,7 @@ describe('prepareSessionWorktree', () => { it('normalizes invalid custom names to a safe default branch', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); @@ -131,7 +135,7 @@ describe('prepareSessionWorktree', () => { expect(result.branchName).toBe('autohand-worktree'); - const addArgs = mockSpawnSync.mock.calls[2]?.[1] as string[]; + const addArgs = mockSpawnSync.mock.calls[3]?.[1] as string[]; expect(addArgs).toEqual([ 'worktree', 'add', @@ -144,6 +148,7 @@ describe('prepareSessionWorktree', () => { it('throws when git worktree add fails', () => { mockSpawnSync .mockReturnValueOnce({ status: 0, stdout: '/tmp/repo\n', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: 'refs/heads/main\n', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'fatal: add failed' }); diff --git a/tests/utils/shellExecution.test.ts b/tests/utils/shellExecution.test.ts new file mode 100644 index 000000000..0a3e0da87 --- /dev/null +++ b/tests/utils/shellExecution.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { configureShellExecution, resolveShellExecution } from '../../src/utils/shellExecution.js'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runCommand } from '../../src/actions/command.js'; +import { executeShellCommandAsync, executeStreamingShellCommand } from '../../src/ui/shellCommand.js'; +import { withCommandCoordination } from '../../src/session/peers/CommandCoordinationGate.js'; +import type { ResourceCommand, ResourceCoordinatorClient } from '../../src/session/peers/ResourceCoordinator.js'; + +afterEach(() => configureShellExecution(undefined)); + +describe('shell execution profiles', () => { + it.skipIf(process.platform === 'win32')('uses the selected shell for command tools and foreground execution', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'autohand-shell-profile-')); + try { + const executable = path.join(directory, 'chosen shell'); + await writeFile(executable, '#!/bin/sh\nprintf "PROFILE_SELECTED\\n"\nexec /bin/sh "$@"\n', { mode: 0o755 }); + configureShellExecution({ executable, dialect: 'posix' }); + const command = await runCommand('printf hello', [], directory, { shell: true }); + expect(command.stdout).toContain('PROFILE_SELECTED'); + expect(command.stdout).toContain('hello'); + const foreground = await executeShellCommandAsync('printf hello', directory); + expect(foreground.output).toContain('PROFILE_SELECTED'); + const streaming = await executeStreamingShellCommand('printf hello', directory, { preferPty: false }); + expect(streaming.output).toContain('PROFILE_SELECTED'); + const checked: ResourceCommand[] = []; + const coordinator = { + commandRequirements: async (command: ResourceCommand) => { checked.push(command); return []; }, + beginLaunch: async () => {}, publishLaunch: async () => {}, finishLaunch: async () => {}, + } as ResourceCoordinatorClient; + await withCommandCoordination({ coordinator }, () => executeShellCommandAsync('printf hello', directory)); + expect(checked[0]).toEqual({ file: 'printf hello', args: [], cwd: directory }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it('keeps a native Windows command in CMD when Git Bash is not selected', () => { + expect(resolveShellExecution('echo hello', { platform: 'win32', environment: { ComSpec: 'C:\\Windows\\cmd.exe' } })) + .toEqual({ file: 'C:\\Windows\\cmd.exe', args: ['/d', '/s', '/c', '"echo hello"'], dialect: 'cmd', windowsVerbatimArguments: true }); + }); + + it('uses a Git Bash native executable rather than CMD', () => { + expect(resolveShellExecution('printf "%s" "$VALUE"', { platform: 'win32', environment: { + MSYSTEM: 'MINGW64', SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe', + } })).toEqual({ file: 'C:\\Program Files\\Git\\bin\\bash.exe', args: ['-lc', 'printf "%s" "$VALUE"'], dialect: 'posix' }); + }); + + it('preserves spaces and quoting when PowerShell is explicitly selected', () => { + configureShellExecution({ executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }); + expect(resolveShellExecution('Write-Output "hello world"', { platform: 'win32', environment: {} })) + .toEqual({ file: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Write-Output "hello world"'], dialect: 'powershell' }); + }); + + it('honors an explicitly configured POSIX shell on Unix', () => { + configureShellExecution({ executable: '/bin/sh' }); + expect(resolveShellExecution('printf ok', { platform: 'linux', environment: { SHELL: '/bin/zsh' } })) + .toEqual({ file: '/bin/sh', args: ['-lc', 'printf ok'], dialect: 'posix' }); + }); +}); diff --git a/tests/webActionExecutor.spec.ts b/tests/webActionExecutor.spec.ts index 808f97707..859234d66 100644 --- a/tests/webActionExecutor.spec.ts +++ b/tests/webActionExecutor.spec.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { FileActionManager } from '../src/actions/filesystem.js'; -import { configureSearch } from '../src/actions/web.js'; +import { configureSearch, configureSearchFromAccount } from '../src/actions/web.js'; import { resolveBrowserToolResponse, setBrowserBridgeOutput, @@ -17,6 +17,22 @@ import type { AgentRuntime } from '../src/types.js'; describe('web tool dispatch', () => { afterEach(() => { shutdownBrowserToolBridge(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns the cap line to the model every time and prints it once per session', async () => { + const message = "You've used your 10 free web searches today. They reset in 2h 5m. For more, upgrade to Pro: https://dev.autohand.ai/search/upgrade?source=cli"; + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_url, init) => init.method === 'GET' + ? Response.json({ available: true, tier: 'free', provider: 'cloudflare' }) + : Response.json({ error: { code: 'web_search_cap_hit', message } }, { status: 429 }))); + await configureSearchFromAccount({ auth: { token: 'ahc_verified_free' } }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const runtime = { config: { configPath: '', openrouter: { apiKey: 'test', model: 'model' } }, workspaceRoot: '/repo', options: {} } as AgentRuntime; + const executor = new ActionExecutor({ runtime, files: {} as FileActionManager, sessionId: 'cap-session', resolveWorkspacePath: file => `/repo/${file}`, confirmDangerousAction: vi.fn().mockResolvedValue(true) }); + expect(await executor.execute({ type: 'web_search', query: 'docs' })).toBe(message); + expect(await executor.execute({ type: 'web_search', query: 'news' })).toBe(message); + expect(log.mock.calls.filter(args => String(args[0]).includes(message))).toHaveLength(1); }); it('routes web_search through the connected Chromium bridge', async () => { diff --git a/tests/windowsShell.integration.test.ts b/tests/windowsShell.integration.test.ts new file mode 100644 index 000000000..baac0157f --- /dev/null +++ b/tests/windowsShell.integration.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { configureShellExecution, getShellExecutionProfile } from '../src/utils/shellExecution.js'; +import { executeShellCommandAsync, executeStreamingShellCommand } from '../src/ui/shellCommand.js'; +import { runCommand } from '../src/actions/command.js'; + +afterEach(() => { configureShellExecution(undefined); vi.unstubAllEnvs(); }); + +describe.skipIf(process.platform !== 'win32')('native Windows shell compatibility', () => { + it('executes Git Bash commands using the native Bash executable', async () => { + vi.stubEnv('MSYSTEM', 'MINGW64'); + vi.stubEnv('SHELL', '/usr/bin/bash'); + expect(getShellExecutionProfile().dialect).toBe('posix'); + const result = await executeShellCommandAsync('printf "%s" "BASH_NATIVE_OK"'); + expect(result.success, result.error).toBe(true); + expect(result.output).toContain('BASH_NATIVE_OK'); + }); + + it.each([ + ['cmd.exe', 'echo CMD_NATIVE_OK', 'CMD_NATIVE_OK'], + ['pwsh.exe', 'Write-Output "POWERSHELL_NATIVE_OK"', 'POWERSHELL_NATIVE_OK'], + ])('uses the explicitly configured %s for both command paths', async (executable, command, expected) => { + configureShellExecution({ executable }); + const tool = await runCommand(command, [], process.cwd(), { shell: true, timeout: 15000 }); + expect(tool.code, tool.stderr).toBe(0); + expect(tool.stdout).toContain(expected); + const foreground = await executeShellCommandAsync(command, process.cwd(), 15000); + expect(foreground.success, foreground.error).toBe(true); + expect(foreground.output).toContain(expected); + }); + + it('preserves CMD quoting of executable paths and JavaScript strings in all command paths', async () => { + configureShellExecution({ executable: 'cmd.exe' }); + const command = `"${process.execPath}" -e "process.stdout.write('QUOTED_NATIVE_OK')"`; + const tool = await runCommand(command, [], process.cwd(), { shell: true, timeout: 15000 }); + expect(tool.stdout, tool.stderr).toContain('QUOTED_NATIVE_OK'); + const foreground = await executeShellCommandAsync(command); + expect(foreground.output, foreground.error).toContain('QUOTED_NATIVE_OK'); + const streaming = await executeStreamingShellCommand(command, process.cwd(), { preferPty: true }); + expect(streaming.output, streaming.error).toContain('QUOTED_NATIVE_OK'); + }); + + it('hands a Git Bash sh installation to real PowerShell without losing spaced paths or options', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'autohand-git-bash-')); + try { + const unixPath = (value: string) => execFileSync('cygpath.exe', ['-u', value], { encoding: 'utf8' }).trim(); + const bin = path.join(directory, 'mock bin'); + await mkdir(bin); + const stub = path.join(directory, 'stub.ps1'); + const record = path.join(directory, 'handoff.json'); + const installDir = path.join(directory, 'install with spaces'); + await writeFile(stub, [ + 'param([switch]$Alpha, [switch]$Fresh, [switch]$NoZit, [string]$Version, [string]$InstallDir)', + '@{ alpha=$Alpha.IsPresent; fresh=$Fresh.IsPresent; noZit=$NoZit.IsPresent; version=$Version; installDir=$InstallDir } | ConvertTo-Json | Set-Content -LiteralPath $env:AUTOHAND_HANDOFF_RECORD', + ].join('\n')); + await writeFile(path.join(bin, 'curl'), '#!/bin/sh\nwhile [ "$#" -gt 0 ]; do if [ "$1" = "-o" ]; then shift; cp "$AUTOHAND_HANDOFF_STUB" "$1"; exit 0; fi; shift; done; exit 1\n', { mode: 0o755 }); + const result = spawnSync('bash.exe', ['-lc', 'export PATH="$AUTOHAND_MOCK_BIN:$PATH"; sh "$AUTOHAND_INSTALL_SCRIPT" --alpha --fresh --no-zit'], { + encoding: 'utf8', timeout: 30000, + env: { ...process.env, AUTOHAND_MOCK_BIN: unixPath(bin), AUTOHAND_INSTALL_SCRIPT: unixPath(path.resolve('install.sh')), + AUTOHAND_HANDOFF_STUB: unixPath(stub), AUTOHAND_HANDOFF_RECORD: record, + AUTOHAND_INSTALL_DIR: installDir, AUTOHAND_VERSION: '0.9.9', AUTOHAND_INSTALL_FIRST_RUN: 'no' }, + }); + expect(result.status, result.stdout + result.stderr).toBe(0); + const handoff = JSON.parse((await readFile(record, 'utf8')).replace(/^\uFEFF/u, '')); + expect(handoff).toMatchObject({ alpha: true, fresh: true, noZit: true, version: '0.9.9' }); + expect(path.normalize(handoff.installDir)).toBe(path.normalize(installDir)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, 45000); +}); diff --git a/tests/worktreeSafety.spec.ts b/tests/worktreeSafety.spec.ts new file mode 100644 index 000000000..c98243127 --- /dev/null +++ b/tests/worktreeSafety.spec.ts @@ -0,0 +1,174 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { WorktreeManager } from '../src/actions/worktree.js'; +import { enterAgentSessionWorktree, exitAgentSessionWorktree } from '../src/core/agent/AgentCommandRuntime.js'; +import { DEFAULT_TOOL_DEFINITIONS, ToolManager } from '../src/core/toolManager.js'; +import { PermissionManager } from '../src/permissions/PermissionManager.js'; +import type { SessionWorktreeInfo } from '../src/utils/sessionWorktree.js'; +import type { AgentAction } from '../src/types.js'; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +async function fixture() { + const container = await mkdtemp(path.join(os.tmpdir(), 'autohand-worktree-safety-')); + roots.push(container); + const root = path.join(container, 'repo'); + await mkdir(root); + git(root, 'init', '-b', 'main'); + git(root, 'config', 'user.name', 'Worktree Tests'); + git(root, 'config', 'user.email', 'tests@autohand.ai'); + await writeFile(path.join(root, 'tracked.txt'), 'original\n'); + git(root, 'add', '.'); + git(root, 'commit', '-qm', 'Initial fixture'); + const host = { + runtime: { workspaceRoot: root }, + sessionWorktreeState: null as (SessionWorktreeInfo & { originalWorkspaceRoot: string }) | null, + switchWorkspaceContext: vi.fn(async (workspaceRoot: string) => { host.runtime.workspaceRoot = workspaceRoot; }), + }; + await enterAgentSessionWorktree(host, 'safety'); + const worktreePath = host.runtime.workspaceRoot; + return { root, host, worktreePath }; +} + +describe('session worktree preservation with real Git', () => { + it.each(['untracked', 'modified', 'staged'])('keeps %s files on exit_worktree with default arguments', async kind => { + const { root, host, worktreePath } = await fixture(); + const file = path.join(worktreePath, kind === 'untracked' ? 'new.txt' : 'tracked.txt'); + await writeFile(file, 'work that must survive\n'); + if (kind === 'staged') git(worktreePath, 'add', '.'); + + const output = await exitAgentSessionWorktree(host); + + expect(existsSync(file), output).toBe(true); + expect(await readFile(file, 'utf8')).toBe('work that must survive\n'); + expect(output).toContain(`Kept worktree ${worktreePath} (branch safety) with changes`); + expect(output).toContain('kept: has changes'); + expect(host.runtime.workspaceRoot).toBe(root); + expect(host.sessionWorktreeState).toBeNull(); + }); + + it('keeps a clean worktree containing commits not on the original branch', async () => { + const { root, host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'committed.txt'), 'committed work\n'); + git(worktreePath, 'add', '.'); + git(worktreePath, 'commit', '-qm', 'Keep this commit'); + const head = git(worktreePath, 'rev-parse', 'HEAD'); + + const output = await exitAgentSessionWorktree(host); + + expect(existsSync(worktreePath), output).toBe(true); + expect(git(root, 'rev-parse', 'safety')).toBe(head); + expect(git(worktreePath, 'status', '--porcelain')).toBe(''); + expect(output).toContain('Kept worktree'); + }); + + it('removes a clean session worktree and only its newly created branch', async () => { + const { root, host, worktreePath } = await fixture(); + await exitAgentSessionWorktree(host); + expect(existsSync(worktreePath)).toBe(false); + expect(git(root, 'branch', '--list', 'safety')).toBe(''); + }); + + it('normalizes an equivalent registered path before removing a clean worktree', async () => { + const { root, worktreePath } = await fixture(); + await new WorktreeManager(root).remove(`${worktreePath}${path.sep}`, { deleteBranch: true }); + expect(existsSync(worktreePath)).toBe(false); + expect(git(root, 'branch', '--list', 'safety')).toBe(''); + }); + + it('compares commits against the original branch even after the original checkout switches branches', async () => { + const { root, host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'committed.txt'), 'work\n'); + git(worktreePath, 'add', '.'); + git(worktreePath, 'commit', '-qm', 'Not on original main'); + git(root, 'checkout', '-b', 'other-branch', 'safety'); + const output = await exitAgentSessionWorktree(host); + expect(existsSync(worktreePath), output).toBe(true); + }); + + it.each([{ keep: true }, {}, { keep: true, discard_changes: true }])('allows a non-discarding exit without a deletion prompt: %j', async args => { + const { host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'new.txt'), 'keep\n'); + const confirmApproval = vi.fn(); + const manager = new ToolManager({ + definitions: DEFAULT_TOOL_DEFINITIONS, confirmApproval, + executor: async action => { + if (action.type !== 'exit_worktree') throw new Error('Unexpected tool'); + return { success: true, output: await exitAgentSessionWorktree(host, action.keep, action.discard_changes) }; + }, + authorization: { permissionManager: new PermissionManager({ mode: 'interactive' }) }, + }); + const [result] = await manager.execute([{ tool: 'exit_worktree', args }]); + expect(result.success).toBe(true); + expect(existsSync(worktreePath)).toBe(true); + expect(confirmApproval).not.toHaveBeenCalled(); + }); + + it('does not treat the legacy force option as authority to discard dirty files', async () => { + const { root, worktreePath } = await fixture(); + const file = path.join(worktreePath, 'new.txt'); + await writeFile(file, 'keep\n'); + const output = await new WorktreeManager(root).remove(worktreePath, { force: true }); + expect(existsSync(file), output).toBe(true); + expect(output).toContain('kept: has changes'); + }); + + it('does not report a dirty merged worktree as removed during cleanup', async () => { + const { root, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'new.txt'), 'keep\n'); + const result = await new WorktreeManager(root).cleanup({ removeMerged: true }); + expect(existsSync(worktreePath)).toBe(true); + expect(result.removed).not.toContain(worktreePath); + }); + + it.each(['allow_once', 'deny'] as const)('requires the deletion approval path before a discard (%s)', async decision => { + const { host, worktreePath } = await fixture(); + await writeFile(path.join(worktreePath, 'new.txt'), 'keep unless approved\n'); + const confirmApproval = vi.fn().mockResolvedValue({ decision }); + const executor = vi.fn(async (action: AgentAction) => { + if (action.type !== 'exit_worktree') throw new Error('Unexpected tool'); + return { success: true as const, output: await exitAgentSessionWorktree(host, action.keep, action.discard_changes) }; + }); + const manager = new ToolManager({ + definitions: DEFAULT_TOOL_DEFINITIONS, + executor, + confirmApproval, + authorization: { permissionManager: new PermissionManager({ mode: 'interactive' }) }, + }); + + const [result] = await manager.execute([{ tool: 'exit_worktree', args: { discard_changes: true } }]); + + expect(confirmApproval).toHaveBeenCalledOnce(); + expect(confirmApproval.mock.calls[0]?.[1]).toMatchObject({ tool: 'delete_path' }); + expect(result.success).toBe(decision === 'allow_once'); + expect(existsSync(worktreePath)).toBe(decision !== 'allow_once'); + expect(executor).toHaveBeenCalledTimes(decision === 'allow_once' ? 1 : 0); + }); + + it('denies explicit discard in the restricted client profile before invoking its executor', async () => { + const executor = vi.fn().mockResolvedValue({ success: true, output: 'removed' }); + const confirmApproval = vi.fn().mockResolvedValue({ decision: 'allow_once' }); + const manager = new ToolManager({ + definitions: DEFAULT_TOOL_DEFINITIONS, executor, confirmApproval, clientContext: 'restricted', + authorization: { permissionManager: new PermissionManager({ mode: 'unrestricted' }) }, + }); + + const [result] = await manager.execute([{ tool: 'exit_worktree', args: { discard_changes: true } }]); + + expect(result.success).toBe(false); + expect(executor).not.toHaveBeenCalled(); + expect(confirmApproval).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/worktreeSessionTools.spec.ts b/tests/worktreeSessionTools.spec.ts index 5f6b6f95f..5153bfac7 100644 --- a/tests/worktreeSessionTools.spec.ts +++ b/tests/worktreeSessionTools.spec.ts @@ -18,6 +18,8 @@ describe('Worktree session tools', () => { const def = DEFAULT_TOOL_DEFINITIONS.find((item) => item.name === 'exit_worktree'); expect(def).toBeDefined(); expect(def!.parameters?.properties).toHaveProperty('keep'); + expect(def!.parameters?.properties).toHaveProperty('discard_changes'); + expect(def!.requiresApproval).toBe(true); }); it('categorizes enter_worktree as meta', () => { diff --git a/tests/zitInstallerIntegration.test.ts b/tests/zitInstallerIntegration.test.ts new file mode 100644 index 000000000..c73d2cd81 --- /dev/null +++ b/tests/zitInstallerIntegration.test.ts @@ -0,0 +1,40 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const installer = readFileSync('install.sh', 'utf8').replace(/\nmain "\$@" \|\| exit 1\s*$/, ''); +describe.skipIf(process.platform === 'win32')('published Zit installer compatibility', () => { + for (const scenario of ['existing', 'disabled', 'download-failure', 'checksum-failure', 'verified'] as const) { + it(`preserves the ${scenario} installation path`, () => { + const root = mkdtempSync(join(tmpdir(), 'autohand-zit-install-')); + try { + const fixtures = join(root, 'fixtures'); + const bin = join(root, 'bin'); + const name = 'zit-v0.1.2-aarch64-apple-darwin'; + mkdirSync(fixtures); mkdirSync(bin); mkdirSync(join(root, name)); + for (const file of ['zit', 'git-zit']) writeFileSync(join(root, name, file), '#!/bin/sh\necho fixture-zit\n'); + const archive = join(root, 'archive.tar.gz'); + const tar = spawnSync('/usr/bin/tar', ['-czf', archive, '-C', root, name]); + expect(tar.status).toBe(0); + const digest = scenario === 'checksum-failure' ? '0'.repeat(64) : createHash('sha256').update(readFileSync(archive)).digest('hex'); + writeFileSync(join(fixtures, 'uname'), '#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo arm64 ;; esac\n'); + writeFileSync(join(fixtures, 'curl'), `#!/bin/sh\necho called >> "$HOME/curl-calls"\n${scenario === 'download-failure' ? 'exit 22' : ''}\nurl= output=\nwhile [ "$#" -gt 0 ]; do case "$1" in -o) output="$2"; shift 2 ;; http*) url="$1"; shift ;; *) shift ;; esac; done\ncase "$url" in *.sha256) echo '${digest}' > "$output" ;; *) cp "$HOME/archive.tar.gz" "$output" ;; esac\n`); + for (const file of ['uname', 'curl']) chmodSync(join(fixtures, file), 0o755); + if (scenario === 'existing') { writeFileSync(join(fixtures, 'zit'), '#!/bin/sh\necho existing\n'); chmodSync(join(fixtures, 'zit'), 0o755); } + const result = spawnSync('/bin/sh', ['-c', `${installer}\ninstall_zit "$1"`, 'test', bin], { + encoding: 'utf8', timeout: 15_000, + env: { HOME: root, PATH: `${fixtures}:/usr/bin:/bin`, AUTOHAND_SKIP_ZIT_INSTALL: scenario === 'disabled' ? '1' : '0' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(join(bin, 'zit'))).toBe(scenario === 'verified'); + if (scenario === 'verified') expect(readFileSync(join(bin, 'git-zit'), 'utf8')).toContain('fixture-zit'); + if (scenario === 'existing' || scenario === 'disabled') expect(existsSync(join(root, 'curl-calls'))).toBe(false); + if (scenario === 'checksum-failure') expect(result.stdout).toContain('checksum verification failed'); + if (scenario === 'download-failure') expect(result.stdout).toContain('Autohand installation continues'); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + } +});