From d3dff58f62fcbe640f9922a67988b3ce35d42eea Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sun, 11 Oct 2026 16:50:15 +0900 Subject: [PATCH] fix: preserve expansion data and interactive approval boundaries --- README.md | 11 +- docs/src/getting-started.md | 32 +-- docs/src/guide/recipes.md | 18 +- docs/src/project/public-readiness.md | 26 +- docs/src/project/roadmap.md | 21 +- man/nshell.1 | 16 +- nshell.asd | 8 +- src/application/command-substitution.lisp | 27 +- .../execute-pipeline-expansion.lisp | 59 ++-- .../execute-pipeline-fragment-expansion.lisp | 32 ++- src/domain/expansion/expand.lisp | 56 +++- src/domain/expansion/fields.lisp | 18 +- src/domain/expansion/glob-matching.lisp | 14 +- src/domain/expansion/parameter-braced.lisp | 7 +- src/domain/expansion/parameter.lisp | 6 +- .../assistant-sidecar-stream.lisp | 2 + src/presentation/repl-agent.lisp | 44 ++- t/e2e/test-smoke.lisp | 18 +- t/unit/test-agent-mode.lisp | 227 ++++++++++++--- t/unit/test-assistant-model-boundary.lisp | 258 +++++++++++++++++- t/unit/test-execute-pipeline-expansion.lisp | 167 ++++++++++++ 21 files changed, 904 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 848117f0..179e247d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The release and Nix flake support `x86_64-linux` only. With [Nix](https://nixos.org/download) and flakes enabled: ```sh -nix run github:nerima-lisp/nshell/v0.6.2 +nix run github:nerima-lisp/nshell/v0.6.3 ``` At the prompt, enter `string upper hello`. Commands and paths colorize live, and a @@ -52,10 +52,17 @@ hard links, and files owned by another user are rejected. A malformed tail is reported without discarding existing bytes; further appends are refused until the file is moved aside. `--no-history` disables persistence. +The interactive `agent` builtin proposes commands for review. Every proposal, +including one classified as safe, requires Enter before execution; blocked +commands cannot be approved. The assistant refuses to start if its sidecar +reports tools or MCP servers outside the permitted initialization contract. +See [Use the assistant](docs/src/guide/recipes.md#use-the-assistant) for controls +and session settings. + ## Install ```sh -nix profile install github:nerima-lisp/nshell/v0.6.2 +nix profile install github:nerima-lisp/nshell/v0.6.3 ``` Pin a release tag rather than following the default branch. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index bea2f854..abe3a930 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -9,13 +9,13 @@ Install [Nix](https://nixos.org/download) and enable its `nix-command` and `flakes` experimental features before running: ```sh -nix run github:nerima-lisp/nshell/v0.6.2 +nix run github:nerima-lisp/nshell/v0.6.3 ``` ## Install ```sh -nix profile install github:nerima-lisp/nshell/v0.6.2 +nix profile install github:nerima-lisp/nshell/v0.6.3 man nshell # the manual page is installed alongside the binary nshell ``` @@ -26,34 +26,34 @@ leave the interactive shell. ### Prebuilt Linux bundle Without Nix, download both assets from the -[v0.6.2 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.2), +[v0.6.3 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.3), verify the checksum, and extract the bundle. On `x86_64-linux`, with `curl`, `sha256sum`, and `tar` available, run these commands in an empty directory: ```sh -curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.2/nshell-v0.6.2-x86_64-linux.tar.gz -curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.2/nshell-v0.6.2-x86_64-linux.tar.gz.sha256 -sha256sum --check nshell-v0.6.2-x86_64-linux.tar.gz.sha256 +curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.3/nshell-v0.6.3-x86_64-linux.tar.gz +curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.3/nshell-v0.6.3-x86_64-linux.tar.gz.sha256 +sha256sum --check nshell-v0.6.3-x86_64-linux.tar.gz.sha256 ``` Proceed only if the checksum command exits successfully and prints -`nshell-v0.6.2-x86_64-linux.tar.gz: OK`: +`nshell-v0.6.3-x86_64-linux.tar.gz: OK`: ```sh -tar -xzf nshell-v0.6.2-x86_64-linux.tar.gz -./nshell-v0.6.2-x86_64-linux/bin/nshell --version -man ./nshell-v0.6.2-x86_64-linux/share/man/man1/nshell.1 -./nshell-v0.6.2-x86_64-linux/bin/nshell +tar -xzf nshell-v0.6.3-x86_64-linux.tar.gz +./nshell-v0.6.3-x86_64-linux/bin/nshell --version +man ./nshell-v0.6.3-x86_64-linux/share/man/man1/nshell.1 +./nshell-v0.6.3-x86_64-linux/bin/nshell ``` Keep the extracted directory intact: the launcher resolves libraries and -helpers relative to itself. The version output must identify `v0.6.2`. +helpers relative to itself. The version output must identify `v0.6.3`. Type `exit` to return to your existing shell. To use `nshell` by name in the examples below, add the extracted `bin` directory to that shell's `PATH`. For Bash or Zsh, from the download directory: ```sh -export PATH="$PWD/nshell-v0.6.2-x86_64-linux/bin:$PATH" +export PATH="$PWD/nshell-v0.6.3-x86_64-linux/bin:$PATH" ``` This changes only the current terminal session. Alternatively, replace @@ -95,14 +95,14 @@ nshell -c 'string upper hello' ### Run a script ```sh -curl --fail --location --output greet.nsh https://raw.githubusercontent.com/nerima-lisp/nshell/v0.6.2/examples/greet.nsh +curl --fail --location --output greet.nsh https://raw.githubusercontent.com/nerima-lisp/nshell/v0.6.3/examples/greet.nsh nshell greet.nsh World ``` Script files support multiline blocks (functions, `if`/`for`/`while`/`switch`), comments, and a `#!` shebang; arguments after the script name are available as `$argv`. See -[`examples/`](https://github.com/nerima-lisp/nshell/tree/v0.6.2/examples) for a +[`examples/`](https://github.com/nerima-lisp/nshell/tree/v0.6.3/examples) for a runnable sample. ### Command line @@ -131,7 +131,7 @@ The Nix build supplies [SBCL](http://www.sbcl.org/), ASDF, and the dependencies. On `x86_64-linux`, with Git and flake-enabled Nix installed: ```sh -git clone --branch v0.6.2 https://github.com/nerima-lisp/nshell +git clone --branch v0.6.3 https://github.com/nerima-lisp/nshell cd nshell nix build ./result/bin/nshell diff --git a/docs/src/guide/recipes.md b/docs/src/guide/recipes.md index 5c66fa9b..b95b1230 100644 --- a/docs/src/guide/recipes.md +++ b/docs/src/guide/recipes.md @@ -39,10 +39,15 @@ Start an interactive assistant task from a terminal: agent "inspect the current job state" ``` -The `agent` builtin is interactive-only. Commands classified as safe execute -automatically. Commands requiring confirmation are shown for review and execute -only after Enter; blocked commands are not executed. Review this policy before -starting a task, especially in a directory containing sensitive data. +The `agent` builtin is interactive-only. Every proposed command, including one +classified as safe, is shown for review and executes only after Enter. Blocked +commands are not executed. Approval applies to one proposal, not the rest of +the task. Review each command, especially in a directory containing sensitive +data. + +The assistant refuses to start if its sidecar reports tools or MCP servers +outside the permitted initialization contract, even if a later initialization +event reports an acceptable configuration. Inspect assistant state and configure the session with `ai`: @@ -104,8 +109,9 @@ job-control lifecycle checks. perl -e '$SIG{ALRM}=sub { exit 124 }; alarm 900; exec @ARGV' nix develop -c sbcl --script scripts/coverage.lisp ``` -The report is written to `coverage/cover-index.html`. Set `NSHELL_COVERAGE_DIR` -to redirect the output. +The report is written to `$TMPDIR/nshell-coverage/cover-index.html` when `TMPDIR` +is set, otherwise to `coverage/cover-index.html`. Set `NSHELL_COVERAGE_DIR` to +choose the output directory explicitly. ## Performance evidence diff --git a/docs/src/project/public-readiness.md b/docs/src/project/public-readiness.md index e42703d4..df8e59c8 100644 --- a/docs/src/project/public-readiness.md +++ b/docs/src/project/public-readiness.md @@ -2,26 +2,26 @@ ## Positioning -nshell targets daily interactive use first. v0.6.1 is a development preview, +nshell targets daily interactive use first. v0.6.2 is a development preview, not a POSIX/fish-compatible script interpreter. ## Capability Matrix -The evidence and statuses below describe the published `v0.6.1` tree, not +The evidence and statuses below describe the published `v0.6.2` tree, not unreleased changes on the default branch. -| Area | Release bar | Published evidence | Status | +| Area | Capability or policy | Published evidence | Status | | --- | --- | --- | --- | | Interactive editor | Live syntax feedback, Emacs bindings, optional vi mode, multiline editing, undo/yank, predictable rendering | input-state, rendering, and PTY integration tests | CI-verified on `x86_64-linux` | | History and suggestions | Persistent history, reverse search, prefix autosuggestions, handling of multiline entries | history, autosuggest, and E2E editing tests | CI-verified on `x86_64-linux` | | Completion | Context-aware command/path/flag completion, candidate menu, deterministic cycling and cancellation | completion domain and REPL rendering tests, hierarchical command resolution, curated external command metadata and selective help-text enrichment | Broader command discovery/cache policy and subcommand coverage remain future work | | Shell language | Functions, control flow, command substitution, expansions, heredocs, here-strings, redirection, pipelines | parser, expansion, source, pipeline, process-substitution, descriptor-duplication, tab-stripping-heredoc, and smoke tests | Tested subset; full POSIX/fish parity is not claimed | -| Process control | Foreground/background jobs, `jobs`/`fg`/`bg`/`disown`, Ctrl-C recovery, terminal restoration; terminal-attached commands have no default timeout, while redirected execution defaults to 3600 seconds and command substitution to 300 seconds | job-control and non-sandboxed PTY integration tests | CI-verified on `x86_64-linux` | -| Distribution | Nix-pinned build, installed man page, release binary smoke, checksummed artifacts | flake build, man page, CI/release workflows, [v0.6.1 release assets](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.1) | Prebuilt `x86_64-linux` binary and SHA-256 checksum published; nixpkgs/Homebrew remain future work | -| Security and operations | Private vulnerability reporting, explicit security scope, validation of secret handling in history/completion/diagnostics | [repository security policy](https://github.com/nerima-lisp/nshell/blob/v0.6.1/SECURITY.md) and [contribution guidelines](contributing.md) | Reporting policy exists; it does not establish a security audit of runtime behavior | +| Process control | Foreground/background jobs, `jobs`/`fg`/`bg`/`disown`, Ctrl-C recovery, terminal restoration; foreground external commands whose standard output is a terminal have no default timeout, while file/pipe output defaults to 3600 seconds and command substitution to 300 seconds | job-control and non-sandboxed PTY integration tests | CI-verified on `x86_64-linux` | +| Distribution | Nix-pinned build, installed man page, release binary smoke, checksummed artifacts | flake build, man page, CI/release workflows, [v0.6.2 release assets](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.2) | Prebuilt `x86_64-linux` binary and SHA-256 checksum published; nixpkgs/Homebrew remain future work | +| Security and operations | Private vulnerability reporting and explicit security scope | [repository security policy](https://github.com/nerima-lisp/nshell/blob/v0.6.2/SECURITY.md) and [contribution guidelines](contributing.md) | Reporting policy exists; it does not establish a security audit of runtime behavior | -The [v0.6.1 CI run](https://github.com/nerima-lisp/nshell/actions/runs/38050042888) -and [release workflow](https://github.com/nerima-lisp/nshell/actions/runs/38052885425) +The [v0.6.2 CI run](https://github.com/nerima-lisp/nshell/actions/runs/38120933504) +and [release workflow](https://github.com/nerima-lisp/nshell/actions/runs/38120952046) validate the tagged tree. The non-sandboxed integration job runs the PTY and external-process cases skipped by the Nix sandbox. @@ -29,8 +29,8 @@ sandbox. ## Release Gates These are the existing nshell gates, not a production-readiness verdict. -The workflow also requires dependency-suite and extracted-artifact checks; -the older published runs linked above do not establish these newer gates. +The linked v0.6.2 release run includes dependency-suite and extracted-artifact +checks, including the extracted executable's real-PTY gate. Before a public release can claim the capabilities listed here: @@ -51,14 +51,16 @@ Before a public release can claim the capabilities listed here: GitHub Release notes, and completion metadata when applicable. 6. Open roadmap gaps remain explicit instead of being implied as complete. -The [release workflow](https://github.com/nerima-lisp/nshell/blob/main/.github/workflows/release.yml) +The [v0.6.2 release workflow](https://github.com/nerima-lisp/nshell/blob/v0.6.2/.github/workflows/release.yml) runs hermetic checks and a non-sandboxed integration job against the same resolved tagged commit, and only then builds the release assets in a draft. A maintainer reviews the assets and release notes before publishing the draft. ## Verification outside CI -The current flake declares only `x86_64-linux`. On another host, the Nix +Run these checks from the checkout being verified. To reproduce the historical +release evidence above, use the `v0.6.2` tag. The flake declares only +`x86_64-linux`. On another host, the Nix checks require an `x86_64-linux` builder. Running the dev shell or PTY suite also requires an `x86_64-linux` execution environment; a remote builder alone does not supply that runtime coverage. diff --git a/docs/src/project/roadmap.md b/docs/src/project/roadmap.md index 22552e89..dc39ef15 100644 --- a/docs/src/project/roadmap.md +++ b/docs/src/project/roadmap.md @@ -15,25 +15,24 @@ shift, and ternary operators; brace expansion; command substitution here-strings `<<<`; and function arguments via `$argv` / `$argv[N]`. This list does not establish that every edge case has been audited. -**Command discovery:** extend help-text-driven discovery beyond the static -command catalog to additional commands and subcommands. See the +**Command discovery:** expand the existing command catalog and selective +help-text enrichment to additional commands and subcommands. See the [completion model](../guide/concepts.md#completion-is-a-knowledge-base-not-a-table). **Distribution:** evaluate nixpkgs and Homebrew packaging. Neither channel is published; a packaging target has not been selected. A prebuilt -`x86_64-linux` bundle is available for v0.6.1; see the -[installation instructions](../getting-started.md#prebuilt-linux-bundle). +`x86_64-linux` bundle is available in the +[v0.6.2 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.2). +See the [installation instructions](../getting-started.md#prebuilt-linux-bundle) +for the current distribution. ## Release evidence -The [v0.6.1 Linux CI run](https://github.com/nerima-lisp/nshell/actions/runs/38050042888) -and [release workflow](https://github.com/nerima-lisp/nshell/actions/runs/38052885425) +The [v0.6.2 Linux CI run](https://github.com/nerima-lisp/nshell/actions/runs/38120933504) +and [release workflow](https://github.com/nerima-lisp/nshell/actions/runs/38120952046) validate the tagged tree. The published -[v0.6.1 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.1) +[v0.6.2 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.2) contains the x86_64-linux bundle and SHA-256 checksum. The non-sandboxed integration suite covers foreground external commands and pipelines with `Ctrl-Z` suspension, `bg` resumption, `fg` terminal handoff, and `Ctrl-C` -interruption. This does not establish compatibility with every terminal; see -[Release readiness](public-readiness.md). - -[GitHub Releases](https://github.com/nerima-lisp/nshell/releases). +interruption. diff --git a/man/nshell.1 b/man/nshell.1 index f5152cdc..f8fb7458 100644 --- a/man/nshell.1 +++ b/man/nshell.1 @@ -1,4 +1,4 @@ -.TH NSHELL 1 "2026-09-30" "nshell 0.6.2" "User Commands" +.TH NSHELL 1 "2026-09-30" "nshell 0.6.3" "User Commands" .SH NAME nshell \- a fish-inspired interactive shell written in Common Lisp .SH SYNOPSIS @@ -233,6 +233,20 @@ provides the following builtins: Run .B help inside the shell for details. +.SH ASSISTANT +The +.B agent +builtin is interactive-only. Every proposed command, including one classified +as safe, requires +.B Enter +before execution. Blocked commands cannot be approved. +.PP +The assistant refuses to start if its sidecar reports tools or MCP servers +outside the permitted initialization contract. Use +.B ai status +to inspect assistant state and +.B ai set +to change session settings. .SH HISTORY FILE .I ~/.nshell_history is created with owner-only permissions. Symlinks, hard links, nonregular files, diff --git a/nshell.asd b/nshell.asd index 059a8f6f..e6da1e25 100644 --- a/nshell.asd +++ b/nshell.asd @@ -19,7 +19,7 @@ :license "MIT" ;; Single source of truth for the version. flake.nix reads this form ;; line-by-line, and release.yml refuses a tag that disagrees with it. - :version "0.6.2" + :version "0.6.3" :homepage "https://github.com/nerima-lisp/nshell" :bug-tracker "https://github.com/nerima-lisp/nshell/issues" :source-control (:git "https://github.com/nerima-lisp/nshell.git") @@ -403,7 +403,7 @@ :in-order-to ((test-op (test-op "nshell/test")))) (asdf:defsystem "nshell/test" - :version "0.6.2" + :version "0.6.3" :author "takeokunn " :maintainer "takeokunn " :license "MIT" @@ -590,7 +590,7 @@ (error "cl-weave tests failed")))) (asdf:defsystem "nshell/weave" - :version "0.6.2" + :version "0.6.3" :author "takeokunn " :maintainer "takeokunn " :license "MIT" @@ -620,7 +620,7 @@ primary suite in nshell/test." :reporter :spec) (error "cl-weave suite failed")))) (asdf:defsystem "nshell/benchmark" - :version "0.6.2" + :version "0.6.3" :author "takeokunn " :maintainer "takeokunn " :license "MIT" diff --git a/src/application/command-substitution.lisp b/src/application/command-substitution.lisp index c8b90d5f..ff23d866 100644 --- a/src/application/command-substitution.lisp +++ b/src/application/command-substitution.lisp @@ -22,16 +22,31 @@ ((char= ch #\)) (decf depth) (when (zerop depth) (return (1+ index)))))))) +(defun %command-substitution-source-text (value) + ;; Re-parsing must retain escape syntax, while data tokens decode only once. + (with-output-to-string (out) + (loop for character across value + for code = (char-code character) + do (when (and nshell.domain.expansion::*fragment-escape-context* + (<= +command-fragment-escape-base+ code + (1- +command-fragment-escape-limit+))) + (write-char #\\ out)) + (write-char (nshell.domain.expansion::%decode-fragment-character character) out)))) + (defun %command-sub-fields-at (context value open-paren &optional preserve-newlines-p) "Run the command substitution at OPEN-PAREN, if balanced and non-empty." (let ((end (nshell.domain.parsing:%balanced-substitution-end value open-paren))) (when (and end (> end (1+ open-paren))) - (values (if preserve-newlines-p - (%execute-command-substitution-output - context (subseq value (1+ open-paren) end)) - (%execute-command-substitution-fields - context (subseq value (1+ open-paren) end))) - (1+ end))))) + (let* ((text (%command-substitution-source-text (subseq value (1+ open-paren) end))) + (replacement + (let ((nshell.domain.expansion::*fragment-escape-context* nil)) + (if preserve-newlines-p + (%execute-command-substitution-output context text) + (%execute-command-substitution-fields context text))))) + (values (if preserve-newlines-p + (nshell.domain.expansion::%encode-fragment-data replacement) + (mapcar #'nshell.domain.expansion::%encode-fragment-data replacement)) + (1+ end)))))) (defun %expand-arithmetic-command-substitution-at (value pos parts len) (when (and (char= (char value pos) #\$) diff --git a/src/application/execute-pipeline-expansion.lisp b/src/application/execute-pipeline-expansion.lisp index f031d99b..c4ae29b9 100644 --- a/src/application/execute-pipeline-expansion.lisp +++ b/src/application/execute-pipeline-expansion.lisp @@ -39,14 +39,21 @@ error.") (let ((value (nshell.domain.parsing:arg-value arg))) (if (nshell.domain.parsing:arg-here-doc-literal-p arg) (list value) - (let ((fields (list ""))) + (let ((fields (list "")) + (nshell.domain.expansion::*fragment-escape-context* + (nshell.domain.expansion::%make-fragment-escape-context))) (dolist (fragment (%source-arg-fragments arg) - (mapcar #'%restore-command-fragment-escapes fields)) + (if environment + (%expand-complete-fragment-fields fields filesystem) + (mapcar #'%restore-command-fragment-escapes fields))) (setf fields (%append-expanded-fragment-fields fields - (%expand-source-fragment-fields - fragment environment filesystem)))))))) + (%protect-quoted-fragment-globs + (%expand-source-fragment-fields + fragment environment filesystem nil) + (nshell.domain.parsing:command-fragment-quote-style + fragment))))))))) (defun %command-node-command-fragments (command-node) (or (nshell.domain.parsing:command-node-command-fragments command-node) @@ -58,8 +65,11 @@ error.") (defun %expand-command-name-fields-from-fragments (command-node environment &optional filesystem) - (let ((fields (list ""))) - (dolist (fragment (%command-node-command-fragments command-node) fields) + (let ((fields (list "")) + (nshell.domain.expansion::*fragment-escape-context* + (nshell.domain.expansion::%make-fragment-escape-context))) + (dolist (fragment (%command-node-command-fragments command-node) + (%expand-complete-fragment-fields fields filesystem)) (let* ((value (nshell.domain.parsing:command-fragment-value fragment)) (protected (%protect-command-fragment-escapes @@ -72,9 +82,13 @@ error.") (nshell.domain.parsing:command-fragment-quote-style fragment) environment - filesystem))) + filesystem nil))) (setf fields - (%append-expanded-fragment-fields fields fragment-fields)))))) + (%append-expanded-fragment-fields + fields + (%protect-quoted-fragment-globs + fragment-fields + (nshell.domain.parsing:command-fragment-quote-style fragment)))))))) (defun %expand-command-name-from-fragments (command-node environment &optional filesystem) @@ -83,16 +97,18 @@ error.") (%expand-command-name-fields-from-fragments command-node environment filesystem))) -(defun %expand-unquoted-source-arg-in-context (context value environment) +(defun %expand-unquoted-source-arg-in-context + (context value environment &optional (glob-p t)) (multiple-value-bind (fields argv-reference-p) (nshell.domain.expansion:argv-reference-fields value) (if argv-reference-p - fields + (mapcar #'nshell.domain.expansion::%encode-fragment-data fields) (loop for expanded in (%expand-command-substitutions context value) append (nshell.domain.expansion:expand-all expanded environment - (shell-context-filesystem context)))))) + (shell-context-filesystem context) + glob-p))))) (defun %expand-double-quoted-source-arg-in-context (context value environment) (list (apply #'concatenate 'string @@ -101,17 +117,18 @@ error.") collect (nshell.domain.expansion:expand-double-quoted expanded environment))))) -(defun %expand-source-fragment-fields-in-context (context fragment) +(defun %expand-source-fragment-fields-in-context + (context fragment &optional (glob-p t)) (let* ((value (nshell.domain.parsing:command-fragment-value fragment)) (protected (%protect-command-fragment-escapes value - (nshell.domain.parsing:command-fragment-escaped-positions + (nshell.domain.parsing:command-fragment-escaped-positions fragment))) (environment (shell-context-environment context))) (nshell.domain.expansion:expand-by-quote-style (nshell.domain.parsing:command-fragment-quote-style fragment) - (%expand-unquoted-source-arg-in-context context protected environment) + (%expand-unquoted-source-arg-in-context context protected environment glob-p) (list protected) (%expand-double-quoted-source-arg-in-context context protected environment)))) @@ -119,14 +136,20 @@ error.") (let ((value (nshell.domain.parsing:arg-value arg))) (if (nshell.domain.parsing:arg-here-doc-literal-p arg) (list value) - (let ((fields (list ""))) + (let ((fields (list "")) + (nshell.domain.expansion::*fragment-escape-context* + (nshell.domain.expansion::%make-fragment-escape-context))) (dolist (fragment (%source-arg-fragments arg) - (mapcar #'%restore-command-fragment-escapes fields)) + (%expand-complete-fragment-fields + fields (shell-context-filesystem context))) (setf fields (%append-expanded-fragment-fields fields - (%expand-source-fragment-fields-in-context - context fragment)))))))) + (%protect-quoted-fragment-globs + (%expand-source-fragment-fields-in-context + context fragment nil) + (nshell.domain.parsing:command-fragment-quote-style + fragment))))))))) (defun %line-command-args (command-node &optional environment) (loop for arg in (nshell.domain.parsing:command-node-args command-node) diff --git a/src/application/execute-pipeline-fragment-expansion.lisp b/src/application/execute-pipeline-fragment-expansion.lisp index 6e6c931b..91aa5c39 100644 --- a/src/application/execute-pipeline-fragment-expansion.lisp +++ b/src/application/execute-pipeline-fragment-expansion.lisp @@ -5,16 +5,19 @@ (+ +command-fragment-escape-base+ 256)) (defun %protect-command-fragment-escapes (value positions) - (let ((protected (copy-seq value))) + (let ((protected (copy-seq (nshell.domain.expansion::%encode-fragment-data value)))) (dolist (position positions protected) (when (and (<= 0 position) (< position (length protected))) - (let ((code (char-code (char protected position)))) + (let ((code (char-code (char value position)))) (when (< code 256) (setf (char protected position) (code-char (+ +command-fragment-escape-base+ code))))))))) (defun %restore-command-fragment-escapes (value) + (when nshell.domain.expansion::*fragment-escape-context* + (return-from %restore-command-fragment-escapes + (nshell.domain.expansion::%decode-fragment-data value))) (with-output-to-string (out) (loop for character across value for code = (char-code character) @@ -30,7 +33,28 @@ append (loop for field in (or fields (list "")) collect (concatenate 'string prefix field)))) -(defun %expand-source-fragment-fields (fragment environment &optional filesystem) +(defun %protect-quoted-fragment-globs (fields style) + (if (null style) + fields + (mapcar (lambda (field) + (map 'string + (lambda (character) + (if (nshell.domain.expansion::glob-char-p character) + (code-char (+ +command-fragment-escape-base+ + (char-code character))) + character)) + field)) + fields))) + +(defun %expand-complete-fragment-fields (fields filesystem) + ;; Globbing sees the complete word, but quoted metacharacters remain protected. + (loop for field in fields + append (mapcar #'%restore-command-fragment-escapes + (nshell.domain.expansion::%expand-glob-with-prefix + field filesystem)))) + +(defun %expand-source-fragment-fields + (fragment environment &optional filesystem (glob-p t)) (let* ((value (nshell.domain.parsing:command-fragment-value fragment)) (protected (%protect-command-fragment-escapes @@ -40,7 +64,7 @@ (nshell.domain.expansion:expand-by-quote-style (nshell.domain.parsing:command-fragment-quote-style fragment) (if environment - (nshell.domain.expansion:expand-all protected environment filesystem) + (nshell.domain.expansion:expand-all protected environment filesystem glob-p) (list protected)) (list protected) (if environment diff --git a/src/domain/expansion/expand.lisp b/src/domain/expansion/expand.lisp index 6abcc204..8ce75bda 100644 --- a/src/domain/expansion/expand.lisp +++ b/src/domain/expansion/expand.lisp @@ -1,6 +1,50 @@ ;;; Shell expansion engine (in-package #:nshell.domain.expansion) +(defvar *fragment-escape-context* nil) + +(defstruct (fragment-escape-context (:constructor %make-fragment-escape-context)) + (encoded (make-hash-table :test #'eql)) + (decoded (make-hash-table :test #'eql)) + (next #xf0000)) + +(defun %encode-fragment-data (value) + ;; Encode data at insertion boundaries, never already encoded expansion text. + ;; One character per token keeps parser indices and substring lengths intact. + (if (null *fragment-escape-context*) + value + (map 'string + (lambda (character) + (let ((code (char-code character)) + (context *fragment-escape-context*)) + (if (or (<= #xe100 code #xe1ff) (<= #xf0000 code #xffffd)) + (or (gethash character (fragment-escape-context-encoded context)) + (let ((next (fragment-escape-context-next context))) + (when (> next #xffffd) + (error "Fragment provenance token space exhausted")) + (let ((token (code-char next))) + (incf (fragment-escape-context-next context)) + (setf (gethash character (fragment-escape-context-encoded context)) token + (gethash token (fragment-escape-context-decoded context)) character) + token))) + character))) + value))) + +(defun %decode-fragment-character (character) + (if (null *fragment-escape-context*) + character + (or (gethash character + (fragment-escape-context-decoded *fragment-escape-context*)) + (let ((code (char-code character))) + (if (<= #xe100 code #xe1ff) + (code-char (- code #xe100)) + character))))) + +(defun %decode-fragment-data (value) + (if *fragment-escape-context* + (map 'string #'%decode-fragment-character value) + value)) + (defun variable-name-char-p (ch) (or (alphanumericp ch) (char= ch #\_))) @@ -23,16 +67,16 @@ Unknown users and incomplete environment data remain literal." (name-end (or slash (length input))) (name (subseq input 1 name-end))) (if (and home user (string= user name)) - (concatenate 'string home + (concatenate 'string (%encode-fragment-data home) (if slash (subseq input slash) "")) input))) (defun expand-tilde (input env) "Expand leading ~ using HOME, or the current-user ~NAME using ENV." (cond - ((string= input "~") (or (nshell.domain.environment:env-get env "HOME") "~")) + ((string= input "~") (%encode-fragment-data (or (nshell.domain.environment:env-get env "HOME") "~"))) ((string-prefix-p "~/" input) - (concatenate 'string (or (nshell.domain.environment:env-get env "HOME") "~") + (concatenate 'string (%encode-fragment-data (or (nshell.domain.environment:env-get env "HOME") "~")) (subseq input 1))) ((and (> (length input) 1) (char= (char input 0) #\~)) (%tilde-user-home input env)) @@ -43,15 +87,15 @@ Unknown users and incomplete environment data remain literal." Returns a one-element list containing PATTERN when it has no glob syntax or no matches." (unless (glob-pattern-p pattern) (return-from expand-glob (list pattern))) - (let* ((root (glob-root pattern)) + (let* ((root (%decode-fragment-data (glob-root pattern))) (files (%glob-candidate-files pattern root filesystem)) (matches nil)) (dolist (file files) - (let ((candidate (%glob-file-candidate pattern root file))) + (let ((candidate (%encode-fragment-data (%glob-file-candidate pattern root file)))) (when (glob-match-p pattern candidate) (push candidate matches)))) (if matches - (sort matches #'string<) + (sort matches #'string< :key #'%decode-fragment-data) (list pattern)))) (defun %glob-candidate-files (pattern root filesystem) diff --git a/src/domain/expansion/fields.lisp b/src/domain/expansion/fields.lisp index 16749d07..c6ecc55c 100644 --- a/src/domain/expansion/fields.lisp +++ b/src/domain/expansion/fields.lisp @@ -44,7 +44,8 @@ (multiple-value-bind (fields next list-reference-p) (%variable-reference-at input start len env) (when list-reference-p - (values (%list-reference-unquoted-field-fragment fields) next))))) + (values (%list-reference-unquoted-field-fragment + (mapcar #'%encode-fragment-data fields)) next))))) (defun %expand-list-references (input env) "Expand fish-style list references as field-producing fragments. @@ -83,14 +84,16 @@ values stay literal." results (list (expand-variables input env))))) -(defun expand-all (input env &optional filesystem) +(defun expand-all (input env &optional filesystem (glob-p t)) "Apply brace, tilde, arithmetic, variable, and glob expansion to INPUT, returning the (possibly multiple) resulting fields." (loop for braced in (expand-braces input) append (loop for argv-expanded in (%expand-list-references (expand-arithmetic (expand-tilde braced env) env) env) - append (%expand-glob-with-prefix argv-expanded filesystem)))) + append (if glob-p + (%expand-glob-with-prefix argv-expanded filesystem) + (list argv-expanded))))) (defmacro expand-by-quote-style (style unquoted-form single-form double-form) "Dispatch on STYLE and evaluate the matching form." @@ -175,18 +178,19 @@ single string. Command substitution is applied by the caller before this." (defun %command-name-field-splitting-required-p (text) (find #\$ text :test #'char=)) -(defun %command-name-unquoted-fields (text env filesystem) - (let ((fields (expand-all text env filesystem))) +(defun %command-name-unquoted-fields (text env filesystem &optional (glob-p t)) + (let ((fields (expand-all text env filesystem glob-p))) (if (%command-name-field-splitting-required-p text) (loop for field in fields append (%split-whitespace-fields field)) fields))) -(defun expand-command-name-fields-by-quote-style (text style env &optional filesystem) +(defun expand-command-name-fields-by-quote-style + (text style env &optional filesystem (glob-p t)) "Expand a command name according to STYLE and shell field-splitting rules." (expand-by-quote-style style - (%command-name-unquoted-fields text env filesystem) + (%command-name-unquoted-fields text env filesystem glob-p) (list text) (list (expand-double-quoted text env)))) diff --git a/src/domain/expansion/glob-matching.lisp b/src/domain/expansion/glob-matching.lisp index e8036ee0..649eee13 100644 --- a/src/domain/expansion/glob-matching.lisp +++ b/src/domain/expansion/glob-matching.lisp @@ -18,7 +18,10 @@ (let ((wild (position-if #'glob-char-p pattern))) (if wild (let* ((prefix (subseq pattern 0 wild)) - (slash (position #\/ prefix :from-end t))) + (slash (position-if + (lambda (character) + (char= (%decode-fragment-character character) #\/)) + prefix :from-end t))) (if slash (subseq prefix 0 (1+ slash)) "./")) (pathname-directory-string pattern)))) @@ -67,10 +70,11 @@ (defun bracket-range-member-p (pattern start end ch) (loop with index = start while (< index end) - thereis (let ((left (char pattern index))) + thereis (let ((left (%decode-fragment-character (char pattern index))) + (ch (%decode-fragment-character ch))) (cond ((and (< (+ index 2) end) (char= (char pattern (1+ index)) #\-)) - (let ((right (char pattern (+ index 2)))) + (let ((right (%decode-fragment-character (char pattern (+ index 2))))) (incf index 3) (char<= left ch right))) (t (incf index) (char= left ch)))))) @@ -105,6 +109,8 @@ (bracket-match-p pidx pattern (char text tidx)) (if parsed-p (and ok (match next-pidx (1+ tidx))) (and (char= (char text tidx) #\[) (match (1+ pidx) (1+ tidx))))))) - (t (and (< tidx text-length) (char= (char pattern pidx) (char text tidx)) + (t (and (< tidx text-length) + (char= (%decode-fragment-character (char pattern pidx)) + (%decode-fragment-character (char text tidx))) (match (1+ pidx) (1+ tidx))))))) (match 0 0)))) diff --git a/src/domain/expansion/parameter-braced.lisp b/src/domain/expansion/parameter-braced.lisp index 25569234..28df012f 100644 --- a/src/domain/expansion/parameter-braced.lisp +++ b/src/domain/expansion/parameter-braced.lisp @@ -58,7 +58,8 @@ a further leading % selects the longest match instead of the shortest." (defun %assign-parameter-default (env name value) "Assign VALUE to NAME in ENV for ${NAME:=word}, preserving export state." - (nshell.domain.environment:env-assign-default! env name value)) + (nshell.domain.environment:env-assign-default! env name (%decode-fragment-data value)) + value) (defun %parse-substring-integer (text) (let ((trimmed (string-trim '(#\Space #\Tab #\Newline #\Return) text))) @@ -113,7 +114,7 @@ Returns NIL when CONTENT is not a length expansion." (defun %parameter-binding (name env) (let ((raw (nshell.domain.environment:env-get env name))) - (%make-parameter-binding name raw (not (null raw)) (or raw "")))) + (%make-parameter-binding name raw (not (null raw)) (%encode-fragment-data (or raw ""))))) (defun %parameter-operator-parts (rest) "Parse REST into a braced parameter operator value." @@ -132,7 +133,7 @@ Returns NIL when CONTENT is not a length expansion." (defun %parameter-required-message (operator word) (if (plusp (length word)) - word + (%decode-fragment-data word) (if (parameter-operator-colon-p operator) "parameter null or not set" "parameter not set"))) diff --git a/src/domain/expansion/parameter.lisp b/src/domain/expansion/parameter.lisp index 0bf7bd22..eda3bbb3 100644 --- a/src/domain/expansion/parameter.lisp +++ b/src/domain/expansion/parameter.lisp @@ -24,12 +24,12 @@ literal, matching fish. Undefined variables expand to the empty string." (write-char ch out)))) ((char= (char input (1+ i)) #\?) (write-string - (or (nshell.domain.environment:env-get env "?") "0") + (%encode-fragment-data (or (nshell.domain.environment:env-get env "?") "0")) out) (incf i)) ((char= (char input (1+ i)) #\!) (write-string - (or (nshell.domain.environment:env-get env "!") "") + (%encode-fragment-data (or (nshell.domain.environment:env-get env "!") "")) out) (incf i)) ((variable-name-start-p (char input (1+ i))) @@ -37,7 +37,7 @@ literal, matching fish. Undefined variables expand to the empty string." (%expand-variable-reference input i len env) (if element (progn - (write-string element out) + (write-string (%encode-fragment-data element) out) (setf i (1- next))) (write-char ch out)))) (t (write-char ch out)))))) diff --git a/src/infrastructure/assistant-sidecar-stream.lisp b/src/infrastructure/assistant-sidecar-stream.lisp index ca1efe8a..4a6723ea 100644 --- a/src/infrastructure/assistant-sidecar-stream.lisp +++ b/src/infrastructure/assistant-sidecar-stream.lisp @@ -420,6 +420,8 @@ (cond ((member kind '(:stream-error :stream-ended)) (return)) + ((eq kind :system-init) + (return)) ((not (eq kind :system-init)) (push event startup-events))))) (when (or (%assistant-sidecar-init-p state) diff --git a/src/presentation/repl-agent.lisp b/src/presentation/repl-agent.lisp index d1d4bdd8..91efed71 100644 --- a/src/presentation/repl-agent.lisp +++ b/src/presentation/repl-agent.lisp @@ -35,11 +35,7 @@ (list (format nil "agent · step ~d/~d" step-number max-steps) (format nil "> ~a" (nshell.application:agent-step-text step)) - (format nil "~@[auto · ~]~a~@[ · ~a~]" - (and (nshell.application:agent-step-classification step) - (eq :safe - (nshell.feature.assistant:assistant-safety-result-classification - (nshell.application:agent-step-classification step)))) + (format nil "~a~@[ · ~a~]" (%agent-classification-label step) (and (nshell.application:agent-step-classification step) (nshell.feature.assistant:assistant-safety-result-reason @@ -83,6 +79,8 @@ (%finish-agent-session "maximum step count reached") (progn (setf *assistant-request-kind* :agent + *assistant-command-origin* :typed + *assistant-command-confirmed-p* nil *input-state* (copy-input-state-with (make-repl-input-state @@ -94,11 +92,11 @@ (let* ((session *agent-session*) (step (and session (nshell.application:agent-session-current-step session)))) - (unless (nshell.application:agent-step-executable-p step) + (unless (and *assistant-command-confirmed-p* + (nshell.application:agent-step-executable-p step)) (return-from %agent-execute-current-step (%render-agent-step-panel))) - (setf *assistant-command-origin* :agent - *assistant-command-confirmed-p* t) + (setf *assistant-command-origin* :agent) (clear-rendered-transient-panel) (clear-rendered-prompt) (%execute-complete-command @@ -140,7 +138,9 @@ *agent-session* (input-state-buffer *input-state*)))) (if (nshell.application:agent-step-executable-p step) - (%agent-execute-current-step) + (progn + (setf *assistant-command-confirmed-p* t) + (%agent-execute-current-step)) (%render-agent-step-panel)))) (defun %agent-editing-event (event) @@ -176,9 +176,11 @@ ((eq type :ctrl-c) (%finish-agent-session "canceled")) ((eq type :enter) - (if (nshell.application:agent-step-blocked-p step) - (%render-agent-step-panel) - (%agent-execute-current-step))) + (if (nshell.application:agent-step-executable-p step) + (progn + (setf *assistant-command-confirmed-p* t) + (%agent-execute-current-step)) + (%render-agent-step-panel))) ((and char (char= char #\e)) (%agent-enter-editing)) ((and char (char= char #\s)) @@ -200,18 +202,12 @@ (%finish-agent-session "AI 応答に提案がありません") (if (nshell.application:agent-session-propose-step *agent-session* proposal) - (progn - (let ((step (nshell.application:agent-session-current-step - *agent-session*))) - (when (nshell.application:agent-step-ast step) - (setf *input-state* - (make-repl-input-state :buffer proposal))) - (%render-agent-step-panel) - (when (and (nshell.application:agent-step-classification step) - (eq :safe - (nshell.feature.assistant:assistant-safety-result-classification - (nshell.application:agent-step-classification step)))) - (%agent-execute-current-step)))) + (let ((step (nshell.application:agent-session-current-step + *agent-session*))) + (when (nshell.application:agent-step-ast step) + (setf *input-state* + (make-repl-input-state :buffer proposal))) + (%render-agent-step-panel)) (%finish-agent-session "maximum step count reached"))))) ((:stream-error :rate-limit-event) (%finish-agent-session diff --git a/t/e2e/test-smoke.lisp b/t/e2e/test-smoke.lisp index ba193cc0..92600121 100644 --- a/t/e2e/test-smoke.lisp +++ b/t/e2e/test-smoke.lisp @@ -470,6 +470,20 @@ The regular read timeout remains short for command-response assertions.") (%assert-pty-child-exit pty))) (%terminate-pty-process pty))))) + (it "e2e-pty-reader-waits-for-complete-job-listing" + (cl-weave:expect-assertions 4) + (dolist (status '("Running" "Done")) + (let ((chunks (list (format nil "[1] ~A " status) "/bin/sleep 3"))) + (with-temporary-function + ('%e2e-pty-read-available + (lambda (fd &rest arguments) + (declare (ignore fd arguments)) + (pop chunks))) + (expect (%e2e-pty-read-until -1 (format nil "~A /bin/sleep 3" status) + :attempts 2 :delay 0) + :to-equal (format nil "[1] ~A /bin/sleep 3" status)) + (expect chunks :to-be-null))))) + (it "e2e-main-interactive-pty-job-control-lifecycle" "The public entry point exposes background job state and fg completion under a real PTY." #-(or darwin linux) @@ -492,12 +506,12 @@ The regular read timeout remains short for command-response assertions.") (%e2e-pty-await-ready fd) (%e2e-pty-write-line fd "/bin/sleep 3 &") (%e2e-pty-write-line fd "jobs") - (let ((running-output (%e2e-pty-read-until fd "Running"))) + (let ((running-output (%e2e-pty-read-until fd "Running /bin/sleep 3"))) (expect (search "Running" running-output) :to-be-truthy) (expect (search "/bin/sleep 3" running-output) :to-be-truthy)) (%e2e-pty-write-line fd "fg 1") (%e2e-pty-write-line fd "jobs") - (let ((done-output (%e2e-pty-read-until fd "Done" :attempts 220))) + (let ((done-output (%e2e-pty-read-until fd "Done /bin/sleep 3" :attempts 220))) (expect (search "Done" done-output) :to-be-truthy) (expect (search "/bin/sleep 3" done-output) :to-be-truthy)) (%e2e-pty-write-line fd "exit") diff --git a/t/unit/test-agent-mode.lisp b/t/unit/test-agent-mode.lisp index 894c4828..76af44cc 100644 --- a/t/unit/test-agent-mode.lisp +++ b/t/unit/test-agent-mode.lisp @@ -50,30 +50,50 @@ (nshell.application:agent-step-status (nshell.application:agent-session-current-step session))))))) - (it "auto-executes-safe-model-steps-and-keeps-invalid-proposals-out-of-the-buffer" + (it "waits-for-a-key-before-dispatching-safe-model-steps" (with-repl-test-state (let ((session (nshell.application:make-agent-session "inspect files" :max-steps 1)) - (executed-p nil) + (dispatches 0) + (key-reads 0) + (continuation nil) (panel nil)) - (setf nshell.presentation::*agent-session* session) + (setf nshell.presentation::*agent-session* session + nshell.presentation::*history-persistence-enabled-p* nil) (with-repl-input-state (:mode :ask-waiting :buffer "inspect files" :cursor-pos 13) (with-temporary-functions - (('nshell.presentation::%agent-execute-current-step - (lambda () (setf executed-p t))) + (('nshell.application::execute-command-node-in-context + (lambda (node context) + (declare (ignore node context)) + (incf dispatches) + (values "fixture-output" 0))) + ('nshell.presentation::read-key-cont + (lambda () (incf key-reads) nil)) ('nshell.presentation::render-transient-panel (lambda (content &rest arguments) (declare (ignore arguments)) (setf panel content))) ('nshell.presentation::render-prompt-cont (lambda () nil))) - (nshell.presentation::%handle-agent-model-event - (nshell.feature.assistant::make-assistant-model-event - 1 :result '(("command" . "ls"))))) - (expect t :to-be executed-p) - (expect (search "auto" (third panel)) :to-be-truthy)) - (setf executed-p nil panel nil + (capture-standard-output + (setf continuation + (nshell.presentation::%handle-agent-model-event + (nshell.feature.assistant::make-assistant-model-event + 1 :result '(("command" . "ls")))))) + (expect 0 :to-equal dispatches) + (expect 0 :to-equal key-reads) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*) + (expect :awaiting-approval :to-equal + (nshell.application:agent-session-state session)) + (expect 0 :to-equal + (nshell.application:agent-session-completed-steps session)) + (expect nil :to-be (search "auto" (third panel))) + (expect (functionp continuation) :to-be-truthy) + (when (functionp continuation) (funcall continuation)) + (expect 1 :to-equal key-reads) + (expect 0 :to-equal dispatches))) + (setf panel nil nshell.presentation::*agent-session* (nshell.application:make-agent-session "inspect files" :max-steps 1)) @@ -92,33 +112,41 @@ (expect "inspect files" :to-equal (nshell.presentation:input-state-buffer nshell.presentation::*input-state*)) - (expect nil :to-be executed-p) (expect (search "invalid" (third panel)) :to-be-truthy))))) (it "executes-a-safe-step-only-after-enter-approval" (with-repl-test-state (let ((session (nshell.application:make-agent-session "list files" :max-steps 1)) - (executed-p nil)) + (dispatches 0) + (observed-origin nil) + (observed-confirmed nil)) (nshell.application:agent-session-propose-step session "ls") (let ((step (nshell.application:agent-session-current-step session))) - (setf nshell.presentation::*agent-session* session) + (setf nshell.presentation::*agent-session* session + nshell.presentation::*history-persistence-enabled-p* nil) (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) (with-temporary-functions - (('nshell.presentation::execute-ast - (lambda (ast) - (declare (ignore ast)) - (setf executed-p t) - 0)) + (('nshell.application::execute-command-node-in-context + (lambda (node context) + (declare (ignore node context)) + (incf dispatches) + (setf observed-origin nshell.application::*execution-origin* + observed-confirmed nshell.application::*execution-confirmed-p*) + (values "fixture-output" 0))) ('nshell.presentation::render-prompt-cont (lambda () nil)) ('nshell.presentation::render-transient-panel (lambda (content &rest arguments) (declare (ignore content arguments)) nil))) - (nshell.presentation::%process-agent-panel-event - (input-key-event :enter))) - (expect t :to-be executed-p) + (expect 0 :to-equal dispatches) + (capture-standard-output + (nshell.presentation::%process-agent-panel-event + (input-key-event :enter)))) + (expect 1 :to-equal dispatches) + (expect :agent :to-be observed-origin) + (expect t :to-be observed-confirmed) (expect :executed :to-equal (nshell.application:agent-step-status step)) (expect nil :to-be nshell.presentation::*agent-session*)))))) @@ -145,36 +173,171 @@ (nshell.presentation:input-state-buffer nshell.presentation::*input-state*)))))) + (it "does-not-mint-approval-in-the-step-executor" + (with-repl-test-state + (let ((session (nshell.application:make-agent-session "list files" :max-steps 1)) + (dispatches 0)) + (nshell.application:agent-session-propose-step session "ls") + (setf nshell.presentation::*agent-session* session + nshell.presentation::*history-persistence-enabled-p* nil) + (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) + (with-temporary-functions + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (values "fixture-output" 0))) + ('nshell.presentation::render-prompt-cont (lambda () nil)) + ('nshell.presentation::render-transient-panel + (lambda (&rest arguments) (declare (ignore arguments)) nil))) + (capture-standard-output + (nshell.presentation::%agent-execute-current-step)) + (expect 0 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*) + (expect :awaiting-approval :to-equal + (nshell.application:agent-session-state session))))))) + + (it "reclassifies-edited-input-before-enter-approval" + (with-repl-test-state + (let ((session (nshell.application:make-agent-session "inspect files" :max-steps 1)) + (dispatches 0) + (classification nil) + (observed-text nil) + (observed-origin nil) + (observed-confirmed nil)) + (nshell.application:agent-session-propose-step session "ls") + (setf nshell.presentation::*agent-session* session + nshell.presentation::*history-persistence-enabled-p* nil) + (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) + (with-temporary-functions + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (let ((step (nshell.application:agent-session-current-step session))) + (setf classification + (nshell.feature.assistant:assistant-safety-result-classification + (nshell.application:agent-step-classification step)) + observed-text (nshell.application:agent-step-text step) + observed-origin nshell.application::*execution-origin* + observed-confirmed nshell.application::*execution-confirmed-p*)) + (values "fixture-output" 0))) + ('nshell.presentation::render-prompt-cont (lambda () nil)) + ('nshell.presentation::render-transient-panel + (lambda (&rest arguments) (declare (ignore arguments)) nil))) + (nshell.presentation::%process-agent-panel-event (input-key-event :char #\e)) + (setf nshell.presentation::*input-state* + (nshell.presentation::make-repl-input-state :buffer "rm -rf /")) + (nshell.presentation::%process-agent-panel-event (input-key-event :enter)) + (expect 0 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*) + (expect t :to-be + (nshell.application:agent-step-blocked-p + (nshell.application:agent-session-current-step session))) + (nshell.presentation::%process-agent-panel-event (input-key-event :char #\e)) + (setf nshell.presentation::*input-state* + (nshell.presentation::make-repl-input-state :buffer "rm fixture")) + (expect 0 :to-equal dispatches) + (capture-standard-output + (nshell.presentation::%process-agent-panel-event (input-key-event :enter))) + (expect 1 :to-equal dispatches) + (expect :confirm :to-be classification) + (expect "rm fixture" :to-equal observed-text) + (expect :agent :to-be observed-origin) + (expect t :to-be observed-confirmed)))))) + + (it "requires-fresh-enter-approval-for-the-next-step" + (with-repl-test-state + (let ((session (nshell.application:make-agent-session "inspect files" :max-steps 2)) + (dispatches 0) + (requests 0) + (approval-at-request :unset) + (dispatch-origins nil) + (dispatch-approvals nil)) + (nshell.application:agent-session-propose-step session "ls") + (setf nshell.presentation::*agent-session* session + nshell.presentation::*history-persistence-enabled-p* nil) + (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) + (with-temporary-functions + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (push nshell.application::*execution-origin* dispatch-origins) + (push nshell.application::*execution-confirmed-p* dispatch-approvals) + (values "fixture-output" 0))) + ('nshell.presentation::%process-ask-submit-output-event + (lambda () + (incf requests) + (setf approval-at-request + nshell.presentation::*assistant-command-confirmed-p*))) + ('nshell.presentation::read-key-cont (lambda () nil)) + ('nshell.presentation::render-prompt-cont (lambda () nil)) + ('nshell.presentation::render-transient-panel + (lambda (&rest arguments) (declare (ignore arguments)) nil))) + (capture-standard-output + (nshell.presentation::%process-agent-panel-event (input-key-event :enter))) + (expect 1 :to-equal dispatches) + (expect 1 :to-equal requests) + (expect nil :to-be approval-at-request) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*) + (nshell.presentation::%handle-agent-model-event + (nshell.feature.assistant::make-assistant-model-event + 2 :result '(("command" . "ls")))) + (expect 1 :to-equal dispatches) + (expect :awaiting-approval :to-equal + (nshell.application:agent-session-state session)) + (capture-standard-output + (nshell.presentation::%process-agent-panel-event (input-key-event :enter))) + (expect 2 :to-equal dispatches) + (expect '(:agent :agent) :to-equal dispatch-origins) + (expect '(t t) :to-equal dispatch-approvals) + (nshell.presentation::%process-agent-panel-event (input-key-event :enter)) + (expect 2 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*)))))) + (it "skips-a-step-with-s-and-does-not-execute-the-rest" (with-repl-test-state (let ((session (nshell.application:make-agent-session "list files" :max-steps 1)) - (executed-p nil)) - (nshell.application:agent-session-propose-step session "ls") + (dispatches 0)) + (nshell.application:agent-session-propose-step session "rm -rf /") (let ((step (nshell.application:agent-session-current-step session))) (setf nshell.presentation::*agent-session* session) (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) - (with-temporary-function - ('nshell.presentation::render-transient-panel + (with-temporary-functions + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (values "fixture-output" 0))) + ('nshell.presentation::render-transient-panel (lambda (content &rest arguments) (declare (ignore content arguments)) - nil)) + nil))) (nshell.presentation::%process-agent-panel-event (input-key-event :char #\s))) (expect :skipped :to-equal (nshell.application:agent-step-status step)) - (expect nil :to-be executed-p) + (expect 0 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*) (expect nil :to-be nshell.presentation::*agent-session*)))))) (it "cancels-an-agent-session-with-q" (with-repl-test-state (let ((session (nshell.application:make-agent-session "list files" - :max-steps 1))) - (nshell.application:agent-session-propose-step session "ls") + :max-steps 1)) + (dispatches 0)) + (nshell.application:agent-session-propose-step session "rm -rf /") (setf nshell.presentation::*agent-session* session) (with-repl-input-state (:mode :insert :buffer "ls" :cursor-pos 2) (with-temporary-functions - (('nshell.presentation::render-prompt-cont + (('nshell.application::execute-command-node-in-context + (lambda (context node) + (declare (ignore context node)) + (incf dispatches) + (values "fixture-output" 0))) + ('nshell.presentation::render-prompt-cont (lambda () nil)) ('nshell.presentation::render-transient-panel (lambda (content &rest arguments) @@ -186,6 +349,8 @@ (nshell.application:agent-session-state session)) (expect "canceled" :to-equal (nshell.application:agent-session-stop-reason session)) + (expect 0 :to-equal dispatches) + (expect nil :to-be nshell.presentation::*assistant-command-confirmed-p*) (expect nil :to-be nshell.presentation::*agent-session*))))) (it "rejects-agent-in-a-noninteractive-session-without-starting-the-sidecar" diff --git a/t/unit/test-assistant-model-boundary.lisp b/t/unit/test-assistant-model-boundary.lisp index 5df608be..f66e3e99 100644 --- a/t/unit/test-assistant-model-boundary.lisp +++ b/t/unit/test-assistant-model-boundary.lisp @@ -3,7 +3,7 @@ (defun %assistant-stop-sidecar (handle) (funcall (symbol-function 'nshell.infrastructure.acl:stop-sidecar) handle)) -(defun %assistant-test-sidecar-script (&optional one-shot-p received-path) +(defun %assistant-test-sidecar-script (&optional one-shot-p received-path unsafe-init) (let ((path (merge-pathnames (format nil "nshell-assistant-sidecar-~D.sh" (get-internal-real-time)) @@ -14,8 +14,12 @@ "if [ \"$1\" = \"--version\" ]; then" " printf '%s\\n' 'test-version'" " exit 0" - "fi" - "printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"StructuredOutput\"],\"mcp_servers\":[]}'" + "fi")) + (write-line line stream)) + (when unsafe-init + (write-line (format nil "printf '%s\\n' '~a'" unsafe-init) stream)) + (dolist (line + '("printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"StructuredOutput\"],\"mcp_servers\":[]}'" "while IFS= read -r line" "do")) (write-line line stream)) @@ -155,6 +159,254 @@ (cdr (assoc "reason" payload :test #'string=))))) (format nil "kind=~S message=~S payload=~S" kind message payload))) +(defun %assistant-test-init-sequence (unsafe-init) + (let* ((script (%assistant-test-sidecar-script nil nil unsafe-init)) + (state (nshell.feature.assistant::%make-assistant-sidecar-state + (namestring script) nil)) + (events (cl-concurrent-kit:make-channel :buffer-size 128)) + (completion (cl-concurrent-kit:make-promise)) + (pending (nshell.feature.assistant::%make-assistant-pending-cell + 0 events completion)) + (write-item (symbol-function + 'nshell.feature.assistant::%assistant-sidecar-write-item)) + (install-threads (symbol-function + 'nshell.feature.assistant::%assistant-sidecar-install-threads)) + (forwarded nil) + (threads nil)) + (unwind-protect + (progn + (setf (nshell.feature.assistant::assistant-sidecar-state-starting-p state) t + (nshell.feature.assistant::assistant-sidecar-state-startup-generation state) 1 + (nshell.feature.assistant::assistant-sidecar-state-pending state) pending) + (expect t :to-be + (nshell.feature.assistant::%assistant-sidecar-request + state 41 '(("message" . "init-sequence")))) + ;; Run the gate synchronously, but keep the real process and IO threads. + (with-temporary-function + ('nshell.feature.assistant::%assistant-sidecar-write-item + (lambda (current item) + (push (nshell.feature.assistant::assistant-write-item-line item) + forwarded) + (funcall write-item current item))) + (with-temporary-function + ('nshell.feature.assistant::%assistant-sidecar-install-threads + (lambda (current generation handle version channel) + (prog1 (funcall install-threads current generation handle version channel) + (setf threads + (list (nshell.feature.assistant::assistant-sidecar-state-reader-thread current) + (nshell.feature.assistant::assistant-sidecar-state-writer-thread current) + (nshell.feature.assistant::assistant-sidecar-state-error-thread current)))))) + (nshell.feature.assistant::%assistant-sidecar-start-worker + state pending 1))) + (if unsafe-init + (progn + (expect :mcp-isolation-failed :to-be + (nshell.feature.assistant::assistant-sidecar-state-disabled-reason state)) + (expect nil :to-be forwarded) + (expect nil :to-be + (nshell.feature.assistant::assistant-sidecar-state-init-p state))) + (progn + (expect nil :to-be + (nshell.feature.assistant::assistant-sidecar-state-disabled-reason state)) + (expect t :to-be + (nshell.feature.assistant::assistant-sidecar-state-init-p state)) + (expect 1 :to-be (length forwarded)) + (expect "init-sequence" :to-equal + (cdr (assoc "message" + (json-kit:parse (first forwarded) :object-type :alist) + :test #'string=))))) + (let ((terminal nil)) + (loop repeat 8 + until terminal + do (multiple-value-bind (event present-p) + (cl-concurrent-kit:recv + events :timeout (cl-date-kit:duration-of-seconds 2)) + (expect t :to-be present-p) + (expect 41 :to-be + (nshell.feature.assistant:assistant-model-event-generation event)) + (when (member + (nshell.feature.assistant:assistant-model-event-kind event) + '(:result :stream-error :stream-ended)) + (setf terminal event)))) + (expect (if unsafe-init :stream-error :result) :to-be + (nshell.feature.assistant:assistant-model-event-kind terminal)) + (when unsafe-init + (expect "assistant sidecar init handshake failed" :to-equal + (cdr (assoc "message" + (nshell.feature.assistant:assistant-model-event-payload terminal) + :test #'string=))))) + (expect t :to-be + (cl-concurrent-kit:await + completion :timeout (cl-date-kit:duration-of-seconds 2))) + (expect t :to-be (cl-concurrent-kit:promise-settled-p completion)) + (expect nil :to-be + (nshell.feature.assistant::assistant-sidecar-state-starting-p state)) + (unless unsafe-init + (nshell.feature.assistant::%assistant-sidecar-stop-state state)) + (dolist (accessor + '(nshell.feature.assistant::assistant-sidecar-state-handle + nshell.feature.assistant::assistant-sidecar-state-reader-thread + nshell.feature.assistant::assistant-sidecar-state-writer-thread + nshell.feature.assistant::assistant-sidecar-state-error-thread + nshell.feature.assistant::assistant-sidecar-state-start-thread + nshell.feature.assistant::assistant-sidecar-state-write-channel)) + (expect nil :to-be (funcall accessor state))) + (dolist (thread threads) + (when thread + (expect nil :to-be (sb-thread:thread-alive-p thread))))) + (nshell.feature.assistant::%assistant-sidecar-stop-state state) + (nshell.feature.assistant::%assistant-sidecar-close-channel events) + (when (probe-file script) + (delete-file script))))) + +(defun %assistant-test-async-init-sequence (unsafe-init) + (with-temporary-output-file (received-path :prefix "nshell-assistant-init-received-") + (with-open-file (stream received-path :direction :output :if-exists :supersede) + (declare (ignore stream))) + (let* ((script (%assistant-test-sidecar-script t received-path unsafe-init)) + (state (nshell.feature.assistant::%make-assistant-sidecar-state + (namestring script) nil)) + (installed (cl-concurrent-kit:make-channel :buffer-size 1)) + (release (cl-concurrent-kit:make-channel :buffer-size 1)) + (install-threads (symbol-function + 'nshell.feature.assistant::%assistant-sidecar-install-threads)) + (pending nil) + (handle nil) + (write-channel nil) + (threads nil)) + (unwind-protect + (with-temporary-function + ('nshell.feature.assistant::%assistant-sidecar-install-threads + (lambda (current generation current-handle version channel) + (let ((ok (funcall install-threads current generation + current-handle version channel))) + (when ok + (setf handle current-handle + write-channel channel + threads + (list (nshell.feature.assistant::assistant-sidecar-state-reader-thread current) + (nshell.feature.assistant::assistant-sidecar-state-writer-thread current) + (nshell.feature.assistant::assistant-sidecar-state-error-thread current))) + ;; Hold the real startup gate until the real request is queued. + (cl-concurrent-kit:send installed t) + (cl-concurrent-kit:recv + release :timeout (cl-date-kit:duration-of-seconds 2))) + ok))) + (unwind-protect + (progn + (expect t :to-be (nshell.feature.assistant::%assistant-sidecar-start state)) + (multiple-value-bind (value present-p) + (cl-concurrent-kit:recv + installed :timeout (cl-date-kit:duration-of-seconds 2)) + (expect t :to-be present-p) + (expect t :to-be value)) + (push (nshell.feature.assistant::assistant-sidecar-state-start-thread state) + threads) + (setf pending (nshell.feature.assistant::%assistant-sidecar-current-pending state)) + (expect t :to-be + (nshell.feature.assistant::%assistant-sidecar-request + state 41 '(("message" . "async-init-sequence")))) + (expect 41 :to-be + (nshell.feature.assistant::assistant-pending-cell-generation pending)) + (multiple-value-bind (event present-p) + (nshell.feature.assistant::%assistant-sidecar-poll state 41) + (expect nil :to-be event) + (expect nil :to-be present-p)) + (cl-concurrent-kit:send release t) + (expect t :to-be + (cl-concurrent-kit:await + (nshell.feature.assistant::assistant-pending-cell-completion pending) + :timeout (cl-date-kit:duration-of-seconds 2))) + (dolist (thread threads) + (expect t :to-be (not (null thread))) + (expect nil :to-be + (eq :timed-out + (sb-thread:join-thread thread :timeout 2 :default :timed-out))) + (expect nil :to-be (sb-thread:thread-alive-p thread))) + (let ((received (uiop:read-file-lines received-path))) + (expect (if unsafe-init 0 1) :to-be (length received)) + (unless unsafe-init + (expect "async-init-sequence" :to-equal + (cdr (assoc "message" + (json-kit:parse (first received) :object-type :alist) + :test #'string=))))) + (let ((terminal nil)) + (loop repeat 8 + until terminal + do (multiple-value-bind (event present-p) + (nshell.feature.assistant::%assistant-sidecar-poll state 41) + (expect t :to-be present-p) + (when (member (nshell.feature.assistant:assistant-model-event-kind event) + '(:result :stream-error :stream-ended)) + (setf terminal event)))) + (expect (if unsafe-init :stream-error :result) :to-be + (nshell.feature.assistant:assistant-model-event-kind terminal)) + (expect 41 :to-be + (nshell.feature.assistant:assistant-model-event-generation terminal)) + (when unsafe-init + (expect "assistant sidecar init handshake failed" :to-equal + (cdr (assoc "message" + (nshell.feature.assistant:assistant-model-event-payload terminal) + :test #'string=))))) + ;; All assertions precede the explicit stop in unwind cleanup. + (expect (if unsafe-init :mcp-isolation-failed nil) :to-be + (nshell.feature.assistant::assistant-sidecar-state-disabled-reason state)) + (dolist (accessor + '(nshell.feature.assistant::assistant-sidecar-state-pending + nshell.feature.assistant::assistant-sidecar-state-retired-pending + nshell.feature.assistant::assistant-sidecar-state-handle + nshell.feature.assistant::assistant-sidecar-state-init-p + nshell.feature.assistant::assistant-sidecar-state-starting-p + nshell.feature.assistant::assistant-sidecar-state-start-thread + nshell.feature.assistant::assistant-sidecar-state-reader-thread + nshell.feature.assistant::assistant-sidecar-state-writer-thread + nshell.feature.assistant::assistant-sidecar-state-error-thread + nshell.feature.assistant::assistant-sidecar-state-write-channel)) + (expect nil :to-be (funcall accessor state))) + (expect t :to-be + (cl-concurrent-kit:promise-settled-p + (nshell.feature.assistant::assistant-pending-cell-completion pending))) + (expect t :to-be + (cl-concurrent-kit:channel-closed-p + (nshell.feature.assistant::assistant-pending-cell-events pending))) + (expect t :to-be (cl-concurrent-kit:channel-closed-p write-channel)) + (expect nil :to-be + (sb-ext:process-alive-p + (nshell.infrastructure.acl:sidecar-handle-process handle)))) + (cl-concurrent-kit:try-send release t) + (nshell.feature.assistant::%assistant-sidecar-stop-state state))) + (nshell.feature.assistant::%assistant-sidecar-close-channel installed) + (nshell.feature.assistant::%assistant-sidecar-close-channel release) + (when pending + (nshell.feature.assistant::%assistant-sidecar-close-channel + (nshell.feature.assistant::assistant-pending-cell-events pending))) + (when (probe-file script) + (delete-file script)))))) + +(describe "assistant-model-init-sequence" + (it "rejects-unsafe-init-before-a-later-safe-init" + (dolist (unsafe-init + '("{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"Bash\"],\"mcp_servers\":[]}" + "{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"StructuredOutput\"],\"mcp_servers\":[{\"name\":\"unexpected\"}]}" + "{\"type\":\"system\",\"subtype\":\"init\"}")) + (%assistant-test-init-sequence unsafe-init))) + (it "accepts-safe-init-and-forwards-the-queued-request" + (%assistant-test-init-sequence nil)) + (it "rejects-init-missing-only-tools-before-a-later-safe-init" + (%assistant-test-async-init-sequence + "{\"type\":\"system\",\"subtype\":\"init\",\"mcp_servers\":[]}")) + (it "rejects-init-missing-only-mcp-servers-before-a-later-safe-init" + (%assistant-test-async-init-sequence + "{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"StructuredOutput\"]}")) + (it "cleans-up-rejected-async-startup-through-normal-terminal-poll" + (dolist (unsafe-init + '("{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"Bash\"],\"mcp_servers\":[]}" + "{\"type\":\"system\",\"subtype\":\"init\",\"tools\":[\"StructuredOutput\"],\"mcp_servers\":[{\"name\":\"unexpected\"}]}" + "{\"type\":\"system\",\"subtype\":\"init\"}")) + (%assistant-test-async-init-sequence unsafe-init))) + (it "receives-one-valid-request-and-cleans-up-through-normal-terminal-poll" + (%assistant-test-async-init-sequence nil))) + (describe "assistant-model-boundary-contracts" (it "replays-a-sanitized-stream-json-fixture-through-the-injected-boundary" (with-temporary-output-file (fixture-path :prefix "nshell-assistant-fixture-") diff --git a/t/unit/test-execute-pipeline-expansion.lisp b/t/unit/test-execute-pipeline-expansion.lisp index 111de7bd..1ec3e3b4 100644 --- a/t/unit/test-execute-pipeline-expansion.lisp +++ b/t/unit/test-execute-pipeline-expansion.lisp @@ -1,5 +1,172 @@ (in-package #:nshell/test) +(describe "fragment-escape-provenance-regressions" + (it "restores an escaped command name" + (cl-weave:expect-assertions 1) + (let* ((env (nshell.domain.environment:make-default-environment)) + (fragment (nshell.domain.parsing:make-command-fragment "echo" nil '(1))) + (node (nshell.domain.parsing:make-command-node + "echo" nil nil nil (list fragment)))) + (expect (nshell.application::%expand-command-name-fields-from-fragments node env) + :to-equal '("echo")))) + (it "preserves private-use literals alongside a genuine escape" + (cl-weave:expect-assertions 4) + (let* ((env (nshell.domain.environment:make-default-environment)) + (data (concatenate 'string + (coerce (loop for code from #xe100 below #xe200 + collect (code-char code)) 'string) + (string (code-char #xf0000)))) + (value (concatenate 'string "$" data)) + (fragment (nshell.domain.parsing:make-command-fragment value nil '(0))) + (arg (nshell.domain.parsing:make-command-arg value nil nil (list fragment)))) + (expect (nshell.application::%expand-source-arg arg env) :to-equal (list value)) + (expect (nshell.application::%expand-source-arg-in-context + (make-test-shell-context :environment env) arg) :to-equal (list value)) + (dolist (style '(:single :double)) + (expect (nshell.application::%expand-source-arg + (nshell.domain.parsing:make-command-arg data style) env) + :to-equal (list data))))) + (it "preserves scalar braced and list data alongside a genuine escape" + (cl-weave:expect-assertions 6) + (let* ((env (nshell.domain.environment:make-default-environment)) + (data (concatenate 'string (string (code-char #xe141)) + (string (code-char #xf0000))))) + (setf env (nshell.domain.environment:env-set env "PUA" data nil)) + (dolist (suffix '("$PUA" "${PUA}")) + (let* ((value (concatenate 'string "$" suffix)) + (fragment (nshell.domain.parsing:make-command-fragment value nil '(0))) + (arg (nshell.domain.parsing:make-command-arg value nil nil (list fragment)))) + (expect (nshell.application::%expand-source-arg arg env) + :to-equal (list (concatenate 'string "$" data))) + (expect (nshell.application::%expand-source-arg-in-context + (make-test-shell-context :environment env) arg) + :to-equal (list (concatenate 'string "$" data))))) + (setf env (nshell.domain.environment:env-set-values env "PUA" (list data data) nil)) + (let* ((fragment (nshell.domain.parsing:make-command-fragment "$$PUA" nil '(0))) + (arg (nshell.domain.parsing:make-command-arg "$$PUA" nil nil (list fragment))) + (expected (list (concatenate 'string "$" data) + (concatenate 'string "$" data)))) + (expect (nshell.application::%expand-source-arg arg env) :to-equal expected) + (expect (nshell.application::%expand-source-arg-in-context + (make-test-shell-context :environment env) arg) :to-equal expected)))) + (it "preserves substitution output alongside a genuine escape" + (cl-weave:expect-assertions 2) + (let ((data (concatenate 'string (string (code-char #xe141)) + (string (code-char #xf0000)))) + (context (make-test-shell-context))) + (with-temporary-function + ('nshell.application::%execute-command-substitution-output + (lambda (&rest args) (declare (ignore args)) data)) + (dolist (style '(nil :double)) + (let* ((fragment (nshell.domain.parsing:make-command-fragment + "$(echo marker)$" style '(14))) + (arg (nshell.domain.parsing:make-command-arg + "$(echo marker)$" style nil (list fragment)))) + (expect (nshell.application::%expand-source-arg-in-context context arg) + :to-equal (list (concatenate 'string data "$")))))))) + (it "retains escape syntax when entering a nested substitution" + (cl-weave:expect-assertions 2) + (let* ((data (string (code-char #xe141))) + (value (concatenate 'string "$(echo $PUA" data ")")) + (fragment (nshell.domain.parsing:make-command-fragment value :double '(7))) + (arg (nshell.domain.parsing:make-command-arg value :double nil (list fragment))) + (seen nil)) + (with-temporary-function + ('nshell.application::%execute-command-substitution-output + (lambda (context text) + (declare (ignore context)) + (setf seen text) + "marker")) + (expect (nshell.application::%expand-source-arg-in-context + (make-test-shell-context) arg) :to-equal '("marker"))) + (expect seen :to-equal (concatenate 'string "echo \\$PUA" data)))) + (it "expands parsed glob paths without treating escaped wildcards as syntax" + (cl-weave:expect-assertions 63) + (let* ((private-directory (concatenate 'string (string (code-char #xe141)) + (string (code-char #xf0000)))) + (cases (list (list "echo dir/*" "dir" t) + (list "echo 'dir/'*" "dir" t) + (list "echo dir/'*'" "dir" nil) + (list "echo dir\\/*" "dir" t) + (list "echo dir/\\*" "dir" nil) + (list "echo dir\\/\\*" "dir" nil) + (list (concatenate 'string "echo '" private-directory "'/*") + private-directory t) + (list (concatenate 'string "echo '" private-directory "'\\/*") + private-directory t) + (list (concatenate 'string "echo '" private-directory "'/\\*") + private-directory nil)))) + (dolist (case cases) + (destructuring-bind (line directory expand-p) case + (let* ((seen-root nil) + (filesystem + (make-test-filesystem + :directory-files + (lambda (root) + (setf seen-root (namestring root)) + (list (make-pathname :directory (list :relative directory) + :name "a" :type "txt"))))) + (result (nshell.domain.parsing:parse-command-line line))) + (expect (nshell.domain.parsing:parse-result-state result) :to-be :complete) + (let* ((arg (first (nshell.domain.parsing:command-node-args + (nshell.domain.parsing:parse-result-ast result)))) + (context (make-test-shell-context :filesystem filesystem))) + (dolist (expand (list + (lambda () + (nshell.application::%expand-source-arg-in-context context arg)) + (lambda () + (nshell.application::%expand-source-arg + arg (nshell.application:shell-context-environment context) + filesystem)))) + (setf seen-root nil) + (expect (funcall expand) + :to-equal (list (concatenate 'string directory + (if expand-p "/a.txt" "/*")))) + (expect seen-root :to-equal + (when expand-p (concatenate 'string directory "/")))) + (setf seen-root nil) + (expect (nshell.application::%expand-source-arg arg nil filesystem) + :to-equal (list (concatenate 'string directory "/*"))) + (expect seen-root :to-be-null))))))) + (it "expands complete command-name globs and preserves quoted wildcards" + (cl-weave:expect-assertions 12) + (dolist (case '(("'dir/'*" t) ("dir/'*'" nil) + ("dir\\/*" t) ("dir/\\*" nil))) + (destructuring-bind (line expand-p) case + (let* ((seen-root nil) + (filesystem + (make-test-filesystem + :directory-files + (lambda (root) + (setf seen-root (namestring root)) + (list #p"dir/tool")))) + (result (nshell.domain.parsing:parse-command-line line))) + (expect (nshell.domain.parsing:parse-result-state result) :to-be :complete) + (expect (nshell.application::%expand-command-name-from-fragments + (nshell.domain.parsing:parse-result-ast result) + (nshell.domain.environment:make-default-environment) + filesystem) + :to-equal (if expand-p "dir/tool" "dir/*")) + (expect seen-root :to-equal (when expand-p "dir/")))))) + (it "keeps parameter assignment data decoded in the environment" + (cl-weave:expect-assertions 4) + (let* ((env (nshell.domain.environment:make-default-environment)) + (data (concatenate 'string (string (code-char #xe141)) + (string (code-char #xf0000)) "$")) + (value (concatenate 'string "${PUA:=" data "}")) + (fragment (nshell.domain.parsing:make-command-fragment + value :double (list (- (length value) 2)))) + (arg (nshell.domain.parsing:make-command-arg value :double nil + (list fragment)))) + (expect (nshell.application::%expand-source-arg arg env) :to-equal (list data)) + (expect (nshell.domain.environment:env-get env "PUA") :to-equal data) + (let ((reference (nshell.domain.parsing:make-command-arg "$PUA" :double))) + (expect (nshell.application::%expand-source-arg reference env) + :to-equal (list data)) + (expect (nshell.application::%expand-source-arg-in-context + (make-test-shell-context :environment env) reference) + :to-equal (list data)))))) + (describe "execute-pipeline-expansion-branch-tests" (it "protects-and-restores-only-valid-byte-escapes" (let* ((wide-character (code-char 955))