From 42799b5434ef363b9659d92744e58f825417ab45 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:00:38 +0900 Subject: [PATCH 1/7] feat(cli): add opt-in shell-local sp shorthand --- docs/cli-reference.md | 26 ++ docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 26 ++ docs/ko/cli-reference.md | 26 ++ docs/zh-CN/cli-reference.md | 26 ++ pyproject.toml | 3 +- session_peer_shorthand/__init__.py | 1 + session_peer_shorthand/__main__.py | 16 ++ session_peer_shorthand/sp.ps1 | 28 ++ session_peer_shorthand/sp.sh | 50 ++++ .../test_distribution_contents.py | 10 + tests/distribution/test_shorthand.py | 243 ++++++++++++++++++ 12 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 session_peer_shorthand/__init__.py create mode 100644 session_peer_shorthand/__main__.py create mode 100644 session_peer_shorthand/sp.ps1 create mode 100644 session_peer_shorthand/sp.sh create mode 100644 tests/distribution/test_shorthand.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 67a6975c..0988c9c6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -104,6 +104,32 @@ are ordinary command errors (JSON when requested, exit 1). No messages are submitted in either case. JSON results still describe submission rather than receipt; these flags introduce no structured JSON message-input protocol. +## Optional interactive shorthand + +For releases containing these assets, Bash/zsh and PowerShell users can opt in to a shell-local alias named `sp`. It invokes the currently selected `session-peer` command with the same arguments, stdin, output, exit codes and permissions. It is not a separate parser or a Python/TypeScript selector. Keep the canonical command in scripts, SSH requests and generated reply instructions. Do not install both implementations simultaneously; switch with the original installation manager and verify which command your shell resolves first. + +The wheel and source distribution include passive activation files in `session_peer_shorthand`; installation does not activate them or edit profiles or PATH. Use the Python interpreter belonging to the selected installation to locate a file: an activated pip virtual environment's interpreter, or the interpreter inside the tool environment reported by `pipx environment --value PIPX_LOCAL_VENVS` or `uv tool dir`. On Unix those tool environments normally contain `session-peer/bin/python`; Windows uses `session-peer/Scripts/python.exe`. Inspect the actual environment instead of assuming its location. The module only prints the asset's path; read the file before sourcing it. + +```bash +asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) +source "$asset" +sp --version +source "$asset" --remove +``` + +```powershell +$asset = & 'C:\path\to\tool\Scripts\python.exe' -I -m session_peer_shorthand powershell +. $asset +sp --version +. $asset -Remove +``` + +Activation refuses an existing `sp` executable, alias or function without replacing it. PowerShell normally already defines `sp` as `Set-ItemProperty`, so activation refuses by default; continue using the canonical command unless you deliberately manage that collision yourself. Bash/zsh must source the file, and PowerShell must dot-source it. These assets do not support cmd.exe. Shell aliases require the shell's normal alias expansion; Bash disables it in non-interactive scripts, which should use the canonical command instead. + +Repeated activation of this unchanged owned alias succeeds without further changes. Removal affects only the alias created in the same shell; it refuses if you replaced it with another alias or command. Closing the shell removes the alias. An upgrade through the original manager keeps the alias pointing at the canonical command, not a copied runtime. Remove the alias before uninstalling or switching implementations; uninstall does not modify a running shell. Do not add these files to profiles unless you intentionally take responsibility for persistent setup and removal. + +Standalone installation remains unchanged and does not install these files automatically. To use the shorthand with standalone, obtain the files from a reviewed checkout or extract `session_peer_shorthand` from the corresponding verified release source distribution. Follow [release verification](../RELEASING.md) rather than sourcing an unverified download. Source the reviewed local file directly, keeping the canonical command on PATH. Updating or removing this manually selected file is your responsibility; it does not change standalone, pipx, uv or pip ownership. Alias collision detection covers only commands visible to the current shell and PATH, not other shells or future PATH changes. + ## Install Python 3.9+, standard library only — no external dependencies. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index c974d5ad..1f347997 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "a4abf8ca375d058a23b3ae1d91698f95c77e59f0ebd98f623da26461e6b3e08b", + "docs/cli-reference.md": "57800e84086c674fd2dd5a2f9015b06e856db07793fbb54d460e067e53f8b38f", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 38fd4741..1413f710 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -103,6 +103,32 @@ session-peer list --output-format json これらのフラグによって構造化された JSON メッセージ入力プロトコルが導入されるわけではありません。 +## 任意の対話型短縮コマンド + +これらのファイルを含むリリースでは、Bash/zsh と PowerShell で現在のシェルだけに有効な `sp` エイリアスを任意で利用できます。現在選択されている `session-peer` コマンドに同じ引数と標準入力を渡し、出力、終了コード、権限も変えません。別の引数解析器や Python/TypeScript の選択器ではありません。スクリプト、SSH リクエスト、自動生成される返信手順では正式なコマンドを使ってください。両実装を同時にインストールせず、元のインストール管理ツールで切り替え、シェルがどのコマンドを先に選ぶか確認してください。 + +wheel とソース配布物には `session_peer_shorthand` 内に有効化ファイルが含まれます。インストールだけでは有効化されず、プロファイルや PATH も変更しません。選択したインストールの Python インタープリターでファイルを探してください。pip の有効な仮想環境、または `pipx environment --value PIPX_LOCAL_VENVS` や `uv tool dir` で確認したツール環境のインタープリターを使います。通常、Unix のツール環境には `session-peer/bin/python`、Windows には `session-peer/Scripts/python.exe` があります。場所を決めつけず実際の環境を確認してください。モジュールはファイルのパスだけを出力します。シェルで読み込む前に内容を確認してください。 + +```bash +asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) +source "$asset" +sp --version +source "$asset" --remove +``` + +```powershell +$asset = & 'C:\path\to\tool\Scripts\python.exe' -I -m session_peer_shorthand powershell +. $asset +sp --version +. $asset -Remove +``` + +既に `sp` という実行ファイル、エイリアス、関数がある場合は上書きせず有効化を拒否します。PowerShell は通常 `sp` を `Set-ItemProperty` のエイリアスとして定義するため、既定では有効化できません。競合を自分で管理すると決めない限り、正式なコマンドを使い続けてください。Bash/zsh では source、PowerShell では dot-source で読み込む必要があります。cmd.exe は対応していません。エイリアスはシェルの通常の展開規則に従います。Bash の非対話型スクリプトでは既定で展開が無効なため、正式なコマンドを使ってください。 + +この方式で作成したエイリアスを変更していなければ、繰り返し有効化しても追加変更なく成功します。削除は同じシェルで作成したエイリアスにだけ作用し、別のエイリアスやコマンドに変更されていれば拒否します。シェルを閉じるとエイリアスも消えます。元の管理ツールで更新した後も、コピーしたランタイムではなく正式なコマンドを参照します。アンインストールや実装の切り替え前にエイリアスを削除してください。パッケージの削除は実行中のシェルを変更しません。永続設定と削除を自分で管理する場合以外は、プロファイルに追加しないでください。 + +standalone のインストールは変更されず、これらのファイルも自動で配置しません。standalone と併用する場合は、確認済みのチェックアウトからファイルを取得するか、対応するリリースの検証済みソース配布物から `session_peer_shorthand` を展開してください。未検証のダウンロードを直接読み込まず、[リリースの検証](../../RELEASING.ja.md)に従ってください。正式なコマンドを PATH で見つけられる状態にして、確認したローカルファイルを直接読み込みます。手動で選択したファイルの更新と削除は利用者の責任であり、standalone、pipx、uv、pip の管理主体は変わりません。競合検査は現在のシェルと PATH から見えるコマンドだけを対象とし、別のシェルや今後の PATH 変更は確認しません。 + ## インストール Python 3.9 以上、標準ライブラリのみ — 外部依存関係はありません。 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 70aa5130..5dc04e38 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -103,6 +103,32 @@ session-peer list --output-format json 않습니다. +## 선택적 대화형 단축 명령 + +이 파일이 포함된 릴리스에서는 Bash/zsh와 PowerShell에서 현재 셸에만 적용되는 `sp` 별칭을 선택적으로 사용할 수 있습니다. 현재 선택된 `session-peer` 명령에 인자와 표준 입력을 그대로 전달하며 출력, 종료 코드, 권한도 동일합니다. 별도의 인자 해석기나 Python/TypeScript 선택기가 아닙니다. 스크립트, SSH 요청, 자동 생성 회신 안내에는 정식 명령을 사용하세요. 두 구현을 동시에 설치하지 마세요. 기존 설치 관리자로 구현을 교체하고 셸에서 어떤 명령이 먼저 선택되는지 확인하세요. + +wheel과 소스 배포 파일의 `session_peer_shorthand`에 활성화 파일이 포함됩니다. 설치만으로 활성화되거나 프로필과 PATH가 바뀌지는 않습니다. 선택한 설치본의 Python 인터프리터로 파일 경로를 확인하세요. 활성화한 pip 가상 환경의 인터프리터 또는 `pipx environment --value PIPX_LOCAL_VENVS`나 `uv tool dir`에서 확인한 도구 환경의 인터프리터를 사용합니다. 일반적으로 Unix 도구 환경에는 `session-peer/bin/python`, Windows에는 `session-peer/Scripts/python.exe`가 있습니다. 경로를 추측하지 말고 실제 환경을 확인하세요. 모듈은 파일 경로만 출력합니다. 셸에서 읽어 실행하기 전에 파일을 검토하세요. + +```bash +asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) +source "$asset" +sp --version +source "$asset" --remove +``` + +```powershell +$asset = & 'C:\path\to\tool\Scripts\python.exe' -I -m session_peer_shorthand powershell +. $asset +sp --version +. $asset -Remove +``` + +이미 `sp` 실행 파일, 별칭 또는 함수가 있으면 덮어쓰지 않고 활성화를 거부합니다. PowerShell은 보통 `sp`를 `Set-ItemProperty`의 별칭으로 정의하므로 기본적으로 활성화가 거부됩니다. 충돌을 직접 관리하기로 결정하지 않았다면 정식 명령을 계속 사용하세요. Bash/zsh에서는 source로, PowerShell에서는 dot-source로 파일을 읽어야 합니다. cmd.exe는 지원하지 않습니다. 별칭은 셸의 일반적인 별칭 확장 규칙을 따릅니다. Bash의 비대화형 스크립트는 기본적으로 별칭 확장을 끄므로 정식 명령을 사용해야 합니다. + +이 방식으로 만든 별칭이 변경되지 않았다면 반복 활성화해도 추가 변경 없이 성공합니다. 제거는 같은 셸에서 생성한 별칭에만 적용되며, 다른 별칭이나 명령으로 바꾸었다면 거부합니다. 셸을 닫으면 별칭도 사라집니다. 기존 설치 관리자로 업데이트하면 별칭은 복사된 실행본이 아니라 정식 명령을 계속 가리킵니다. 제거하거나 구현을 교체하기 전에 별칭을 먼저 제거하세요. 패키지 제거는 실행 중인 셸을 바꾸지 않습니다. 영구 설정과 제거를 직접 관리할 의사가 없다면 프로필에 이 파일을 추가하지 마세요. + +standalone 설치 방식은 바뀌지 않으며 이 파일들을 자동 설치하지 않습니다. standalone과 함께 쓰려면 검토한 체크아웃에서 파일을 가져오거나 해당 릴리스의 검증된 소스 배포 파일에서 `session_peer_shorthand`를 추출하세요. 검증되지 않은 다운로드를 바로 실행하지 말고 [릴리스 검증](../../RELEASING.ko.md) 절차를 따르세요. PATH에서 정식 명령을 찾을 수 있게 한 뒤 검토한 로컬 파일을 직접 읽으세요. 직접 선택한 파일의 업데이트와 제거는 사용자의 책임이며 standalone, pipx, uv, pip의 관리 주체는 바뀌지 않습니다. 충돌 검사는 현재 셸과 PATH에 보이는 명령만 확인하며 다른 셸이나 향후 PATH 변경까지 확인하지 않습니다. + ## 설치 Python 3.9+, 표준 라이브러리 전용 — 외부 의존성 없음. diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index ef55bf8d..743a3abc 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -62,6 +62,32 @@ session-peer list --output-format json `--json --output-format json` 是合法的;无论先后顺序,将 `--json` 与 `--output-format text` 组合都会报错。无效或矛盾的输出选项属于 argparse 用法错误(stderr,退出码 2);消息来源冲突属于普通命令错误(请求时为 JSON,退出码 1)。无论哪种情况均不会提交任何消息。JSON 结果仍然描述提交情况而非接收情况;这些标志并未引入结构化的 JSON 消息输入协议。 +## 可选的交互式短命令 + +在包含这些文件的版本中,Bash/zsh 和 PowerShell 用户可以自行启用仅在当前 shell 生效的 `sp` 别名。它调用当前选中的 `session-peer` 命令,保持参数、标准输入、输出、退出码和权限不变。它不是独立的参数解析器,也不是 Python/TypeScript 选择器。脚本、SSH 请求和自动生成的回复指令仍应使用正式命令。不要同时安装两个实现;切换时使用原安装管理工具,并确认 shell 优先找到哪个命令。 + +wheel 和源码发行包在 `session_peer_shorthand` 中包含启用脚本。安装不会自动启用,也不会修改配置文件或 PATH。请使用所选安装对应的 Python 解释器定位文件:已激活的 pip 虚拟环境解释器,或通过 `pipx environment --value PIPX_LOCAL_VENVS`、`uv tool dir` 查到的工具环境解释器。Unix 工具环境通常包含 `session-peer/bin/python`,Windows 使用 `session-peer/Scripts/python.exe`。请查看实际环境,不要猜测位置。模块只输出文件路径;在 shell 中加载前请先审阅文件。 + +```bash +asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) +source "$asset" +sp --version +source "$asset" --remove +``` + +```powershell +$asset = & 'C:\path\to\tool\Scripts\python.exe' -I -m session_peer_shorthand powershell +. $asset +sp --version +. $asset -Remove +``` + +如果已有 `sp` 可执行文件、别名或函数,脚本会拒绝启用而不会覆盖。PowerShell 通常已将 `sp` 定义为 `Set-ItemProperty` 的别名,因此默认会拒绝启用;除非您明确决定自行处理冲突,否则请继续使用正式命令。Bash/zsh 必须通过 source 加载,PowerShell 必须通过 dot-source 加载。不支持 cmd.exe。别名遵循 shell 的正常展开规则;Bash 在非交互脚本中默认关闭别名展开,因此脚本应使用正式命令。 + +如果此方式创建的别名没有被修改,重复启用会成功且不产生额外变更。移除操作只针对同一 shell 中创建的别名;若已被替换成其他别名或命令,则拒绝移除。关闭 shell 也会清除别名。通过原管理工具升级后,别名仍指向正式命令,而非复制的运行时。在卸载或切换实现前请先移除别名;包卸载不会修改正在运行的 shell。除非您愿意自行负责持久配置及其移除,否则不要把这些文件加入 shell 配置文件。 + +standalone 安装方式不变,不会自动安装这些文件。如果要搭配 standalone 使用,请从已审阅的检出目录取得文件,或从对应版本经过验证的源码发行包提取 `session_peer_shorthand`。遵循[发行验证](../../RELEASING.zh-CN.md)流程,不要直接加载未经验证的下载。确保 PATH 能找到正式命令,再直接加载审阅后的本地文件。手动选择文件的更新和移除由您负责,不会改变 standalone、pipx、uv 或 pip 的管理归属。冲突检查仅覆盖当前 shell 和 PATH 中可见的命令,不覆盖其他 shell 或将来的 PATH 变更。 + ## 安装 Python 3.9+,仅限标准库 —— 无外部依赖。 diff --git a/pyproject.toml b/pyproject.toml index 6eddf2a5..ea5dfe16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ include = [ "/.gitattributes", "/session_peer_core", "/session_peer_relay", + "/session_peer_shorthand", "/session_peer.py", "/session_peer_mcp.py", "/tools/generate_session_peer.py", @@ -71,4 +72,4 @@ include = [ ] [tool.hatch.build.targets.wheel] -only-include = ["session_peer_relay", "session_peer.py", "session_peer_mcp.py"] +only-include = ["session_peer_relay", "session_peer_shorthand", "session_peer.py", "session_peer_mcp.py"] diff --git a/session_peer_shorthand/__init__.py b/session_peer_shorthand/__init__.py new file mode 100644 index 00000000..9b7951f1 --- /dev/null +++ b/session_peer_shorthand/__init__.py @@ -0,0 +1 @@ +"""Passive, opt-in interactive shell assets; no installation side effects.""" diff --git a/session_peer_shorthand/__main__.py b/session_peer_shorthand/__main__.py new file mode 100644 index 00000000..430ef16f --- /dev/null +++ b/session_peer_shorthand/__main__.py @@ -0,0 +1,16 @@ +"""Locate an activation asset using this installation's Python interpreter.""" + +import argparse +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("shell", choices=("bash", "powershell")) + args = parser.parse_args() + filename = "sp.sh" if args.shell == "bash" else "sp.ps1" + print(Path(__file__).resolve().with_name(filename)) + + +if __name__ == "__main__": + main() diff --git a/session_peer_shorthand/sp.ps1 b/session_peer_shorthand/sp.ps1 new file mode 100644 index 00000000..972b920a --- /dev/null +++ b/session_peer_shorthand/sp.ps1 @@ -0,0 +1,28 @@ +# Dot-source in the current scope. Use -Remove to remove only our alias. +param([Alias('Remove')][switch] $SessionPeerSpRemove) + +if ($MyInvocation.InvocationName -ne '.') { + throw 'Dot-source sp.ps1 to change the current PowerShell scope.' +} +$SessionPeerSpExisting = @(Get-Command -Name sp -All -ListImported -ErrorAction SilentlyContinue) +$SessionPeerSpMarker = Get-Variable -Name SessionPeerSpOwned -Scope Local -ErrorAction SilentlyContinue +if ($SessionPeerSpExisting.Count -eq 1 -and $SessionPeerSpMarker -and $SessionPeerSpMarker.Value -eq $true -and + $SessionPeerSpExisting[0].CommandType -eq 'Alias' -and $SessionPeerSpExisting[0].Definition -eq 'session-peer') { + if ($SessionPeerSpRemove) { + Remove-Item -LiteralPath Alias:sp -ErrorAction Stop + Remove-Variable -Name SessionPeerSpOwned -ErrorAction SilentlyContinue + } + return +} +if ($SessionPeerSpExisting.Count -gt 0) { + throw 'sp already exists (PowerShell normally uses it for Set-ItemProperty). Nothing was changed.' +} +if ($SessionPeerSpRemove) { + Remove-Variable -Name SessionPeerSpOwned -ErrorAction SilentlyContinue + return +} +if (-not (Get-Command -Name session-peer -ListImported -ErrorAction SilentlyContinue)) { + throw 'session-peer is unavailable; select its existing installation on PATH first.' +} +Set-Alias -Name sp -Value session-peer -Scope Local -ErrorAction Stop +$SessionPeerSpOwned = $true diff --git a/session_peer_shorthand/sp.sh b/session_peer_shorthand/sp.sh new file mode 100644 index 00000000..0f0f1c03 --- /dev/null +++ b/session_peer_shorthand/sp.sh @@ -0,0 +1,50 @@ +# Source in Bash or zsh: source /reviewed/path/sp.sh +# Remove only our alias: source /reviewed/path/sp.sh --remove +# No profile/PATH edits, command execution, argument reconstruction or eval. +if [ -n "${BASH_VERSION-}" ]; then + if [ "${BASH_SOURCE[0]}" = "$0" ]; then + printf '%s\n' 'Source sp.sh in Bash or zsh; executing it cannot change your shell.' >&2 + exit 1 + fi +elif [ -n "${ZSH_VERSION-}" ]; then + case "$ZSH_EVAL_CONTEXT" in + *:file) ;; + *) printf '%s\n' 'Source sp.sh in Bash or zsh; executing it cannot change your shell.' >&2; exit 1 ;; + esac +else + printf '%s\n' 'sp.sh supports Bash and zsh only.' >&2 + return 1 2>/dev/null || exit 1 +fi + +case "$#:${1-}" in + 0:|1:--remove) ;; + *) printf '%s\n' 'Usage: source sp.sh [--remove]' >&2; return 2 ;; +esac + +# A marker alone is insufficient: never remove an alias changed by its owner. +if [ "${_SESSION_PEER_SP_OWNED-}" = 1 ]; then + case "$(alias sp 2>/dev/null)" in + "alias sp='session-peer'"|"sp=session-peer") + if [ "${1-}" = --remove ]; then + unalias sp + unset _SESSION_PEER_SP_OWNED + fi + return 0 + ;; + esac +fi + +if command -v sp >/dev/null 2>&1; then + printf '%s\n' 'sp already exists; inspect type -a sp. Nothing was changed.' >&2 + return 1 +fi +if [ "${1-}" = --remove ]; then + unset _SESSION_PEER_SP_OWNED + return 0 +fi +if ! command -v session-peer >/dev/null 2>&1; then + printf '%s\n' 'session-peer is unavailable; select its existing installation on PATH first.' >&2 + return 1 +fi +alias sp='session-peer' +_SESSION_PEER_SP_OWNED=1 diff --git a/tests/distribution/test_distribution_contents.py b/tests/distribution/test_distribution_contents.py index 8a2df7e5..06e4ae81 100644 --- a/tests/distribution/test_distribution_contents.py +++ b/tests/distribution/test_distribution_contents.py @@ -35,6 +35,7 @@ def test_wheel_contains_exact_runtime_contract(self): "session_peer.py", "session_peer_mcp.py", *self.source_files("session_peer_relay"), + *self.source_files("session_peer_shorthand"), } with zipfile.ZipFile(self.wheel) as archive: names = set(archive.namelist()) @@ -72,6 +73,7 @@ def test_sdist_contains_exact_source_and_documentation_contract(self): "tools/generate_session_peer.py", *self.source_files("session_peer_core"), *self.source_files("session_peer_relay"), + *self.source_files("session_peer_shorthand"), *self.source_files("docs"), *self.source_files("deploy/examples"), *self.source_files("plugins"), @@ -109,6 +111,14 @@ def test_artifacts_exclude_private_state_and_development_trees(self): self.assertNotIn("client_secret", basename, name) self.assertFalse(any(PurePosixPath(name).name == "cc_peer.py" for name in wheel_names + sdist_names)) + def test_shorthand_is_passive_packaged_data_not_an_entry_point(self): + with zipfile.ZipFile(self.wheel) as archive: + entrypoints = next(name for name in archive.namelist() if name.endswith("/entry_points.txt")) + self.assertNotIn("\nsp =", archive.read(entrypoints).decode("utf-8")) + for filename in ("sp.sh", "sp.ps1"): + relative = "session_peer_shorthand/" + filename + self.assertEqual(archive.read(relative), (ROOT / relative).read_bytes()) + if __name__ == "__main__": unittest.main() diff --git a/tests/distribution/test_shorthand.py b/tests/distribution/test_shorthand.py new file mode 100644 index 00000000..3d6b8725 --- /dev/null +++ b/tests/distribution/test_shorthand.py @@ -0,0 +1,243 @@ +"""Opt-in aliases in isolated shells; no live sessions or user profile writes.""" + +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +ASSETS = ROOT / "session_peer_shorthand" + + +class ShorthandTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="session-peer-sp-") + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.bin = self.directory / "bin" + self.bin.mkdir() + self.env = dict(os.environ, HOME=str(self.directory), USERPROFILE=str(self.directory), + SESSION_PEER_NO_UPDATE_NOTICE="1", PATH=str(self.bin) + os.pathsep + os.environ["PATH"]) + self.fixture = self.directory / "capture.py" + self.fixture.write_text( + "import json,sys\nprint(json.dumps({'args':sys.argv[1:],'stdin':sys.stdin.read()},ensure_ascii=False))\n" + "sys.exit(7 if '--fail' in sys.argv else 0)\n", encoding="utf-8") + self.canonical(self.fixture) + + def canonical(self, program): + if os.name == "nt": + (self.bin / "session-peer.cmd").write_text( + '@"' + sys.executable + '" "' + str(program) + '" %*\r\n', encoding="utf-8") + else: + command = self.bin / "session-peer" + command.write_text("#!/bin/sh\nexec " + shlex.quote(sys.executable) + " " + + shlex.quote(str(program)) + ' "$@"\n', encoding="utf-8") + command.chmod(0o700) + + def shells(self): + return [name for name in ("bash", "zsh") if shutil.which(name)] + + def run_shell(self, shell, text, input_text=""): + script = self.directory / (shell + "-test.sh") + script.write_text("set -u\n" + ("shopt -s expand_aliases\n" if shell == "bash" else "") + text, + encoding="utf-8") + return subprocess.run([shell, "--noprofile", "--norc", str(script)] if shell == "bash" else + [shell, "-f", str(script)], env=self.env, input=input_text, + capture_output=True, text=True, timeout=15) + + @property + def source(self): + return "source " + shlex.quote(str(ASSETS / "sp.sh")) + + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") + def test_alias_preserves_argv_stdin_exit_and_option_separator(self): + for shell in self.shells(): + with self.subTest(shell=shell): + result = self.run_shell(shell, self.source + "\nsp send --message='--값 $;`literal`' -- 'space value' 雪 --fail\nexit $?\n", + "stdin 값\n") + self.assertEqual(result.returncode, 7, result.stderr) + self.assertEqual(json.loads(result.stdout), {"args": ["send", "--message=--값 $;`literal`", "--", "space value", "雪", "--fail"], "stdin": "stdin 값\n"}) + + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") + def test_actual_cli_equivalence(self): + self.canonical(ROOT / "session_peer.py") + for shell in self.shells(): + for args in ("--version", "--help", "list --agent claude --json", + "send --to missing-target-for-isolated-test --dry-run -m 'hello 雪' --json", "--invalid-option"): + with self.subTest(shell=shell, args=args): + expected = self.run_shell(shell, "session-peer " + args + "\n") + actual = self.run_shell(shell, self.source + "\nsp " + args + "\n") + self.assertEqual((actual.returncode, actual.stdout, actual.stderr), + (expected.returncode, expected.stdout, expected.stderr)) + + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") + def test_collision_refuses_alias_function_and_executable(self): + for shell in self.shells(): + for kind in ("alias", "function", "executable"): + with self.subTest(shell=shell, kind=kind): + prefix = "alias sp='unrelated-command'\n" if kind == "alias" else "sp() { printf unrelated; }\n" + if kind == "executable": + prefix = "" + executable = self.bin / "sp" + executable.write_text("#!/bin/sh\nprintf unrelated\n", encoding="utf-8") + executable.chmod(0o700) + result = self.run_shell(shell, prefix + self.source + "\nactivation_status=$?\ntype sp\nexit $activation_status\n") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("already exists", result.stderr) + self.assertIn("sp", result.stdout) + if kind == "executable": + executable.unlink() + + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") + def test_repeated_activation_owned_removal_update_and_uninstall(self): + for shell in self.shells(): + result = self.run_shell(shell, self.source + "\n" + self.source + "\n" + + self.source + " --remove\n" + self.source + " --remove\n" + + "command -v sp && exit 9\n" + self.source + "\nsp before-update\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["args"], ["before-update"]) + # Re-selecting an installation on PATH changes the canonical command, + # not a separately pinned runtime or generated launcher. + updated = self.directory / "updated.py" + updated.write_text("print('updated implementation')\n", encoding="utf-8") + script = (self.source + "\nPATH=" + shlex.quote(str(self.directory / "new-bin")) + ":$PATH\n" + "sp --version\n" + self.source + " --remove\n") + new_bin = self.directory / "new-bin" + new_bin.mkdir(exist_ok=True) + launcher = new_bin / "session-peer" + launcher.write_text("#!/bin/sh\nexec " + shlex.quote(sys.executable) + " " + shlex.quote(str(updated)) + "\n", encoding="utf-8") + launcher.chmod(0o700) + result = self.run_shell(shell, script) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "updated implementation") + + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") + def test_changed_alias_is_never_removed_and_missing_runtime_refused(self): + for shell in self.shells(): + result = self.run_shell(shell, self.source + "\nalias sp='other'\n" + self.source + " --remove\nactivation_status=$?\nalias sp\nexit $activation_status\n") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("other", result.stdout) + result = self.run_shell(shell, "PATH=/nonexistent\n" + self.source + "\n") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("unavailable", result.stderr) + + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") + def test_executing_asset_and_invalid_activation_arguments_refused(self): + for shell in self.shells(): + result = subprocess.run([shell, str(ASSETS / "sp.sh")], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertIn("Source", result.stderr) + result = self.run_shell(shell, self.source + " --unknown\n") + self.assertEqual(result.returncode, 2) + + def test_asset_locator_is_read_only_and_constrained(self): + for shell, filename in (("bash", "sp.sh"), ("powershell", "sp.ps1")): + result = subprocess.run([sys.executable, "-m", "session_peer_shorthand", shell], cwd=ROOT, + env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(Path(result.stdout.strip()), ASSETS / filename) + result = subprocess.run([sys.executable, "-m", "session_peer_shorthand", "cmd"], cwd=ROOT, + env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + + @unittest.skipUnless(os.name == "nt" or shutil.which("pwsh"), "PowerShell not installed") + def test_powershell_contract(self): + shell = shutil.which("powershell") if os.name == "nt" else shutil.which("pwsh") + if not shell: + self.skipTest("PowerShell unavailable") + script = self.directory / "contract.ps1" + asset = str(ASSETS / "sp.ps1").replace("'", "''") + fixture = str(self.fixture).replace("'", "''") + python = sys.executable.replace("'", "''") + # This removal is fixture-only in a child process: the activator never + # removes PowerShell's default ReadOnly/AllScope sp=Set-ItemProperty. + script.write_text("""$ErrorActionPreference = 'Stop' +$OutputEncoding = New-Object System.Text.UTF8Encoding($false) +[Console]::OutputEncoding = $OutputEncoding +$asset = '__ASSET__' +function Require($condition, $description) { if (-not $condition) { throw $description } } +Set-Alias -Name sp -Value Set-ItemProperty -Option ReadOnly,AllScope -Force -Scope Global +$refused = $false +try { . $asset } catch { $refused = $true } +Require $refused 'default alias was not refused' +Require ((Get-Alias sp).Definition -eq 'Set-ItemProperty') 'default alias changed' +Remove-Item Alias:sp -Force +function session-peer { & '__PYTHON__' '__FIXTURE__' @args } +Set-StrictMode -Version 2.0 +. $asset +. $asset +$result = sp send '--message=값 $;`literal`' -- 'space value' '雪' --fail | ConvertFrom-Json +Require ($LASTEXITCODE -eq 7) 'native exit code changed' +Require ($result.args.Count -eq 6) 'argument count changed' +Require ($result.args[1] -eq '--message=값 $;`literal`') 'argument changed' +function session-peer { 'updated implementation'; $global:LASTEXITCODE = 0 } +Require ((sp --version) -eq 'updated implementation') 'canonical selection not followed after update' +. $asset -Remove +. $asset -Remove +Require (-not (Get-Command sp -ListImported -ErrorAction SilentlyContinue)) 'owned alias not removed' +function sp { 'unrelated' } +$refused = $false +try { . $asset } catch { $refused = $true } +Require $refused 'function collision not refused' +Remove-Item Function:sp +. $asset +Set-Alias -Name sp -Value Get-Item +$refused = $false +try { . $asset -Remove } catch { $refused = $true } +Require $refused 'changed alias removed' +Require ((Get-Alias sp).Definition -eq 'Get-Item') 'changed alias overwritten' +Remove-Item Alias:sp +Remove-Item Function:session-peer +Remove-Item '__CANONICAL__' +$refused = $false +try { . $asset } catch { $refused = $true } +Require $refused 'missing canonical command not refused' +exit 0 +""".replace("__ASSET__", asset).replace("__PYTHON__", python).replace("__FIXTURE__", fixture) + .replace("__CANONICAL__", str(self.bin / ("session-peer.cmd" if os.name == "nt" else "session-peer")).replace("'", "''")), encoding="utf-8-sig") + result = subprocess.run([shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)], + env=self.env, input="", capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + @unittest.skipUnless(os.name == "nt" or shutil.which("pwsh"), "PowerShell not installed") + def test_powershell_actual_cli_equivalence(self): + shell = shutil.which("powershell") if os.name == "nt" else shutil.which("pwsh") + if not shell: + self.skipTest("PowerShell unavailable") + script = self.directory / "native-equivalence.ps1" + script.write_text("""$ErrorActionPreference = 'Stop' +$OutputEncoding = New-Object System.Text.UTF8Encoding($false) +[Console]::OutputEncoding = $OutputEncoding +Remove-Item Alias:sp -Force -ErrorAction SilentlyContinue +function session-peer { & '__PYTHON__' '__CLI__' @args } +. '__ASSET__' +$cases = @( + @('--version'), @('--help'), @('list', '--agent', 'claude', '--json'), + @('send', '--to', 'missing-target-for-isolated-test', '--dry-run', '-m', 'hello 雪', '--json'), + @('--invalid-option') +) +foreach ($arguments in $cases) { + $ErrorActionPreference = 'Continue' + $expected = (& session-peer @arguments 2>$null | Out-String) + $expectedExit = $LASTEXITCODE + $actual = (& sp @arguments 2>$null | Out-String) + $actualExit = $LASTEXITCODE + if ($actual -ne $expected -or $actualExit -ne $expectedExit) { throw 'canonical output or exit mismatch' } +} +exit 0 +""".replace("__PYTHON__", sys.executable.replace("'", "''")) + .replace("__CLI__", str(ROOT / "session_peer.py").replace("'", "''")) + .replace("__ASSET__", str(ASSETS / "sp.ps1").replace("'", "''")), encoding="utf-8-sig") + result = subprocess.run([shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)], + env=self.env, input="", capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() From 7644ee7832c5888e19b449d8e19715433640ec59 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:02:06 +0900 Subject: [PATCH 2/7] test(cli): preserve literal PowerShell separator in shorthand fixture --- tests/distribution/test_shorthand.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/distribution/test_shorthand.py b/tests/distribution/test_shorthand.py index 3d6b8725..695884e3 100644 --- a/tests/distribution/test_shorthand.py +++ b/tests/distribution/test_shorthand.py @@ -172,10 +172,12 @@ def test_powershell_contract(self): Set-StrictMode -Version 2.0 . $asset . $asset -$result = sp send '--message=값 $;`literal`' -- 'space value' '雪' --fail | ConvertFrom-Json +$expected = session-peer send '--message=값 $;`literal`' '--' 'space value' '雪' --fail | ConvertFrom-Json +$result = sp send '--message=값 $;`literal`' '--' 'space value' '雪' --fail | ConvertFrom-Json Require ($LASTEXITCODE -eq 7) 'native exit code changed' Require ($result.args.Count -eq 6) 'argument count changed' Require ($result.args[1] -eq '--message=값 $;`literal`') 'argument changed' +Require (($result | ConvertTo-Json -Compress) -eq ($expected | ConvertTo-Json -Compress)) 'alias changed native argument input' function session-peer { 'updated implementation'; $global:LASTEXITCODE = 0 } Require ((sp --version) -eq 'updated implementation') 'canonical selection not followed after update' . $asset -Remove @@ -220,6 +222,7 @@ def test_powershell_actual_cli_equivalence(self): $cases = @( @('--version'), @('--help'), @('list', '--agent', 'claude', '--json'), @('send', '--to', 'missing-target-for-isolated-test', '--dry-run', '-m', 'hello 雪', '--json'), + @('send', '--to', 'missing-target-for-isolated-test', '--dry-run', '--json', '--', '-leading 雪'), @('--invalid-option') ) foreach ($arguments in $cases) { From 369f58c3b5f23159741aa0e5e30c35045dba7f58 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:03:52 +0900 Subject: [PATCH 3/7] test(cli): make Windows capture encoding explicit --- tests/distribution/test_shorthand.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/distribution/test_shorthand.py b/tests/distribution/test_shorthand.py index 695884e3..93418c98 100644 --- a/tests/distribution/test_shorthand.py +++ b/tests/distribution/test_shorthand.py @@ -26,7 +26,7 @@ def setUp(self): SESSION_PEER_NO_UPDATE_NOTICE="1", PATH=str(self.bin) + os.pathsep + os.environ["PATH"]) self.fixture = self.directory / "capture.py" self.fixture.write_text( - "import json,sys\nprint(json.dumps({'args':sys.argv[1:],'stdin':sys.stdin.read()},ensure_ascii=False))\n" + "import json,sys\nprint(json.dumps({'args':sys.argv[1:],'stdin':sys.stdin.buffer.read().decode('utf-8')}))\n" "sys.exit(7 if '--fail' in sys.argv else 0)\n", encoding="utf-8") self.canonical(self.fixture) @@ -168,7 +168,7 @@ def test_powershell_contract(self): Require $refused 'default alias was not refused' Require ((Get-Alias sp).Definition -eq 'Set-ItemProperty') 'default alias changed' Remove-Item Alias:sp -Force -function session-peer { & '__PYTHON__' '__FIXTURE__' @args } +function session-peer { $input | & '__PYTHON__' '__FIXTURE__' @args } Set-StrictMode -Version 2.0 . $asset . $asset @@ -178,6 +178,8 @@ def test_powershell_contract(self): Require ($result.args.Count -eq 6) 'argument count changed' Require ($result.args[1] -eq '--message=값 $;`literal`') 'argument changed' Require (($result | ConvertTo-Json -Compress) -eq ($expected | ConvertTo-Json -Compress)) 'alias changed native argument input' +$result = 'stdin α' | sp input | ConvertFrom-Json +Require ($result.stdin.Trim() -eq 'stdin α') 'pipeline stdin changed' function session-peer { 'updated implementation'; $global:LASTEXITCODE = 0 } Require ((sp --version) -eq 'updated implementation') 'canonical selection not followed after update' . $asset -Remove From 07d5913986722082920ac759ef0ea4f0a9eaa1bc Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:07:47 +0900 Subject: [PATCH 4/7] fix(cli): isolate shorthand removal from caller shell arguments --- docs/cli-reference.md | 2 +- docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 2 +- docs/ko/cli-reference.md | 2 +- docs/zh-CN/cli-reference.md | 2 +- session_peer_shorthand/sp-remove.sh | 32 +++++++++++++++++++ session_peer_shorthand/sp.sh | 20 +++--------- .../test_distribution_contents.py | 2 +- tests/distribution/test_shorthand.py | 17 ++++++---- 9 files changed, 54 insertions(+), 27 deletions(-) create mode 100644 session_peer_shorthand/sp-remove.sh diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0988c9c6..9ad3b23d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -114,7 +114,7 @@ The wheel and source distribution include passive activation files in `session_p asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) source "$asset" sp --version -source "$asset" --remove +source "${asset%/*}/sp-remove.sh" ``` ```powershell diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 1f347997..6d960b20 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "57800e84086c674fd2dd5a2f9015b06e856db07793fbb54d460e067e53f8b38f", + "docs/cli-reference.md": "165c11eaa44db400f426c3a5dbec64b1392b548274dccafaac093ebc27ec70f6", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 1413f710..cbc881bd 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -113,7 +113,7 @@ wheel とソース配布物には `session_peer_shorthand` 内に有効化ファ asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) source "$asset" sp --version -source "$asset" --remove +source "${asset%/*}/sp-remove.sh" ``` ```powershell diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 5dc04e38..31391ff9 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -113,7 +113,7 @@ wheel과 소스 배포 파일의 `session_peer_shorthand`에 활성화 파일이 asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) source "$asset" sp --version -source "$asset" --remove +source "${asset%/*}/sp-remove.sh" ``` ```powershell diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index 743a3abc..1db761e0 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -72,7 +72,7 @@ wheel 和源码发行包在 `session_peer_shorthand` 中包含启用脚本。安 asset=$("/path/to/tool/python" -I -m session_peer_shorthand bash) source "$asset" sp --version -source "$asset" --remove +source "${asset%/*}/sp-remove.sh" ``` ```powershell diff --git a/session_peer_shorthand/sp-remove.sh b/session_peer_shorthand/sp-remove.sh new file mode 100644 index 00000000..07b4862a --- /dev/null +++ b/session_peer_shorthand/sp-remove.sh @@ -0,0 +1,32 @@ +# Source in the same Bash/zsh scope to remove only our unchanged owned alias. +# A separate file avoids interpreting inherited caller positional arguments. +if [ -n "${BASH_VERSION-}" ]; then + if [ "${BASH_SOURCE[0]}" = "$0" ]; then + printf '%s\n' 'Source sp-remove.sh in Bash or zsh.' >&2 + exit 1 + fi +elif [ -n "${ZSH_VERSION-}" ]; then + case "$ZSH_EVAL_CONTEXT" in + *:file) ;; + *) printf '%s\n' 'Source sp-remove.sh in Bash or zsh.' >&2; exit 1 ;; + esac +else + printf '%s\n' 'sp-remove.sh supports Bash and zsh only.' >&2 + # The exit fallback is needed when an unsupported shell executes the file. + # shellcheck disable=SC2317 + return 1 2>/dev/null || exit 1 +fi +if [ "${_SESSION_PEER_SP_OWNED-}" = 1 ]; then + case "$(alias sp 2>/dev/null)" in + "alias sp='session-peer'"|"sp=session-peer") + unalias sp + unset _SESSION_PEER_SP_OWNED + return 0 + ;; + esac +fi +if command -v sp >/dev/null 2>&1; then + printf '%s\n' 'sp was not created by this activation or has changed. Nothing was removed.' >&2 + return 1 +fi +unset _SESSION_PEER_SP_OWNED diff --git a/session_peer_shorthand/sp.sh b/session_peer_shorthand/sp.sh index 0f0f1c03..71f58123 100644 --- a/session_peer_shorthand/sp.sh +++ b/session_peer_shorthand/sp.sh @@ -1,5 +1,5 @@ # Source in Bash or zsh: source /reviewed/path/sp.sh -# Remove only our alias: source /reviewed/path/sp.sh --remove +# Remove only our alias: source /reviewed/path/sp-remove.sh # No profile/PATH edits, command execution, argument reconstruction or eval. if [ -n "${BASH_VERSION-}" ]; then if [ "${BASH_SOURCE[0]}" = "$0" ]; then @@ -13,22 +13,16 @@ elif [ -n "${ZSH_VERSION-}" ]; then esac else printf '%s\n' 'sp.sh supports Bash and zsh only.' >&2 + # The exit fallback is needed when an unsupported shell executes the file. + # shellcheck disable=SC2317 return 1 2>/dev/null || exit 1 fi -case "$#:${1-}" in - 0:|1:--remove) ;; - *) printf '%s\n' 'Usage: source sp.sh [--remove]' >&2; return 2 ;; -esac - -# A marker alone is insufficient: never remove an alias changed by its owner. +# Do not inspect positional arguments: source without arguments inherits its +# caller's positional parameters in Bash. Activation always means activation. if [ "${_SESSION_PEER_SP_OWNED-}" = 1 ]; then case "$(alias sp 2>/dev/null)" in "alias sp='session-peer'"|"sp=session-peer") - if [ "${1-}" = --remove ]; then - unalias sp - unset _SESSION_PEER_SP_OWNED - fi return 0 ;; esac @@ -38,10 +32,6 @@ if command -v sp >/dev/null 2>&1; then printf '%s\n' 'sp already exists; inspect type -a sp. Nothing was changed.' >&2 return 1 fi -if [ "${1-}" = --remove ]; then - unset _SESSION_PEER_SP_OWNED - return 0 -fi if ! command -v session-peer >/dev/null 2>&1; then printf '%s\n' 'session-peer is unavailable; select its existing installation on PATH first.' >&2 return 1 diff --git a/tests/distribution/test_distribution_contents.py b/tests/distribution/test_distribution_contents.py index 06e4ae81..706917de 100644 --- a/tests/distribution/test_distribution_contents.py +++ b/tests/distribution/test_distribution_contents.py @@ -115,7 +115,7 @@ def test_shorthand_is_passive_packaged_data_not_an_entry_point(self): with zipfile.ZipFile(self.wheel) as archive: entrypoints = next(name for name in archive.namelist() if name.endswith("/entry_points.txt")) self.assertNotIn("\nsp =", archive.read(entrypoints).decode("utf-8")) - for filename in ("sp.sh", "sp.ps1"): + for filename in ("sp.sh", "sp-remove.sh", "sp.ps1"): relative = "session_peer_shorthand/" + filename self.assertEqual(archive.read(relative), (ROOT / relative).read_bytes()) diff --git a/tests/distribution/test_shorthand.py b/tests/distribution/test_shorthand.py index 93418c98..4d9ad174 100644 --- a/tests/distribution/test_shorthand.py +++ b/tests/distribution/test_shorthand.py @@ -55,6 +55,10 @@ def run_shell(self, shell, text, input_text=""): def source(self): return "source " + shlex.quote(str(ASSETS / "sp.sh")) + @property + def remove_source(self): + return "source " + shlex.quote(str(ASSETS / "sp-remove.sh")) + @unittest.skipIf(os.name == "nt", "POSIX shell contracts") def test_alias_preserves_argv_stdin_exit_and_option_separator(self): for shell in self.shells(): @@ -98,7 +102,7 @@ def test_collision_refuses_alias_function_and_executable(self): def test_repeated_activation_owned_removal_update_and_uninstall(self): for shell in self.shells(): result = self.run_shell(shell, self.source + "\n" + self.source + "\n" + - self.source + " --remove\n" + self.source + " --remove\n" + + self.remove_source + "\n" + self.remove_source + "\n" + "command -v sp && exit 9\n" + self.source + "\nsp before-update\n") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["args"], ["before-update"]) @@ -107,7 +111,7 @@ def test_repeated_activation_owned_removal_update_and_uninstall(self): updated = self.directory / "updated.py" updated.write_text("print('updated implementation')\n", encoding="utf-8") script = (self.source + "\nPATH=" + shlex.quote(str(self.directory / "new-bin")) + ":$PATH\n" - "sp --version\n" + self.source + " --remove\n") + "sp --version\n" + self.remove_source + "\n") new_bin = self.directory / "new-bin" new_bin.mkdir(exist_ok=True) launcher = new_bin / "session-peer" @@ -120,7 +124,7 @@ def test_repeated_activation_owned_removal_update_and_uninstall(self): @unittest.skipIf(os.name == "nt", "POSIX shell contracts") def test_changed_alias_is_never_removed_and_missing_runtime_refused(self): for shell in self.shells(): - result = self.run_shell(shell, self.source + "\nalias sp='other'\n" + self.source + " --remove\nactivation_status=$?\nalias sp\nexit $activation_status\n") + result = self.run_shell(shell, self.source + "\nalias sp='other'\n" + self.remove_source + "\nactivation_status=$?\nalias sp\nexit $activation_status\n") self.assertEqual(result.returncode, 1, result.stderr) self.assertIn("other", result.stdout) result = self.run_shell(shell, "PATH=/nonexistent\n" + self.source + "\n") @@ -128,13 +132,14 @@ def test_changed_alias_is_never_removed_and_missing_runtime_refused(self): self.assertIn("unavailable", result.stderr) @unittest.skipIf(os.name == "nt", "POSIX shell contracts") - def test_executing_asset_and_invalid_activation_arguments_refused(self): + def test_execution_refused_and_caller_arguments_do_not_change_activation(self): for shell in self.shells(): result = subprocess.run([shell, str(ASSETS / "sp.sh")], env=self.env, capture_output=True, text=True) self.assertEqual(result.returncode, 1) self.assertIn("Source", result.stderr) - result = self.run_shell(shell, self.source + " --unknown\n") - self.assertEqual(result.returncode, 2) + result = self.run_shell(shell, "set -- --remove 'caller argument'\n" + self.source + "\n" + self.source + "\nsp preserved\n" + self.remove_source + "\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["args"], ["preserved"]) def test_asset_locator_is_read_only_and_constrained(self): for shell, filename in (("bash", "sp.sh"), ("powershell", "sp.ps1")): From dc5a93cf55af094648a4369d65ff52e3380b4ed4 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:17:10 +0900 Subject: [PATCH 5/7] fix(cli): prevent shorthand activation from autoloading modules --- session_peer_shorthand/sp.ps1 | 11 ++++--- tests/distribution/test_shorthand.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/session_peer_shorthand/sp.ps1 b/session_peer_shorthand/sp.ps1 index 972b920a..08fb6a62 100644 --- a/session_peer_shorthand/sp.ps1 +++ b/session_peer_shorthand/sp.ps1 @@ -4,10 +4,13 @@ param([Alias('Remove')][switch] $SessionPeerSpRemove) if ($MyInvocation.InvocationName -ne '.') { throw 'Dot-source sp.ps1 to change the current PowerShell scope.' } -$SessionPeerSpExisting = @(Get-Command -Name sp -All -ListImported -ErrorAction SilentlyContinue) +# Wildcard syntax forces enumeration rather than exact-name command discovery. +# -ListImported alone still permits exact-name misses to auto-import a module. +$SessionPeerSpExisting = @(Get-Command -Name '[s]p' -All -ListImported -ErrorAction SilentlyContinue) $SessionPeerSpMarker = Get-Variable -Name SessionPeerSpOwned -Scope Local -ErrorAction SilentlyContinue -if ($SessionPeerSpExisting.Count -eq 1 -and $SessionPeerSpMarker -and $SessionPeerSpMarker.Value -eq $true -and - $SessionPeerSpExisting[0].CommandType -eq 'Alias' -and $SessionPeerSpExisting[0].Definition -eq 'session-peer') { +$SessionPeerSpVisibleAlias = Get-Alias -Name sp -ErrorAction SilentlyContinue +if ($SessionPeerSpVisibleAlias -and $SessionPeerSpMarker -and $SessionPeerSpMarker.Value -eq $true -and + $SessionPeerSpVisibleAlias.Definition -eq 'session-peer') { if ($SessionPeerSpRemove) { Remove-Item -LiteralPath Alias:sp -ErrorAction Stop Remove-Variable -Name SessionPeerSpOwned -ErrorAction SilentlyContinue @@ -21,7 +24,7 @@ if ($SessionPeerSpRemove) { Remove-Variable -Name SessionPeerSpOwned -ErrorAction SilentlyContinue return } -if (-not (Get-Command -Name session-peer -ListImported -ErrorAction SilentlyContinue)) { +if (-not (Get-Command -Name '[s]ession-peer' -ListImported -ErrorAction SilentlyContinue)) { throw 'session-peer is unavailable; select its existing installation on PATH first.' } Set-Alias -Name sp -Value session-peer -Scope Local -ErrorAction Stop diff --git a/tests/distribution/test_shorthand.py b/tests/distribution/test_shorthand.py index 4d9ad174..3cb079b9 100644 --- a/tests/distribution/test_shorthand.py +++ b/tests/distribution/test_shorthand.py @@ -189,6 +189,13 @@ def test_powershell_contract(self): Require ((sp --version) -eq 'updated implementation') 'canonical selection not followed after update' . $asset -Remove . $asset -Remove +Require (-not (Get-Alias -Name sp -ErrorAction SilentlyContinue)) 'owned alias not removed' +. $asset +function sp { 'hidden command appeared after activation' } +. $asset -Remove +Require (-not (Get-Alias -Name sp -ErrorAction SilentlyContinue)) 'hidden function prevented owned alias removal' +Require ((Get-Command -Name sp).CommandType -eq 'Function') 'hidden function was deleted' +Remove-Item Function:sp Require (-not (Get-Command sp -ListImported -ErrorAction SilentlyContinue)) 'owned alias not removed' function sp { 'unrelated' } $refused = $false @@ -214,6 +221,48 @@ def test_powershell_contract(self): env=self.env, input="", capture_output=True, text=True, timeout=30) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + @unittest.skipUnless(os.name == "nt" or shutil.which("pwsh"), "PowerShell not installed") + def test_powershell_collision_lookup_never_autoloads_modules(self): + shell = shutil.which("powershell") if os.name == "nt" else shutil.which("pwsh") + if not shell: + self.skipTest("PowerShell unavailable") + modules = self.directory / "modules" + module = modules / "SpLookupProbe" + module.mkdir(parents=True) + marker = self.directory / "autoloaded" + module.joinpath("SpLookupProbe.psm1").write_text( + "[IO.File]::WriteAllText('" + str(marker).replace("'", "''") + "', 'loaded')\n" + "function sp { 'unimported' }; function session-peer { 'unimported' }\n" + "Export-ModuleMember -Function sp,session-peer\n", encoding="utf-8-sig") + module.joinpath("SpLookupProbe.psd1").write_text( + "@{RootModule='SpLookupProbe.psm1';ModuleVersion='1.0.0';" + "GUID='01900000-0000-7000-8000-000000000001';FunctionsToExport=@('sp','session-peer')}\n", + encoding="utf-8-sig") + script = self.directory / "autoload-lookup.ps1" + script.write_text("""$ErrorActionPreference = 'Stop' +Remove-Item Alias:sp -Force -ErrorAction SilentlyContinue +$env:PSModulePath = '__MODULES__' + [IO.Path]::PathSeparator + $env:PSModulePath +$asset = '__ASSET__' +. $asset +if (Test-Path '__MARKER__') { throw 'sp lookup auto-imported a module' } +. $asset -Remove +Remove-Item '__CANONICAL__' +$refused = $false +try { . $asset } catch { $refused = $true } +if (-not $refused) { throw 'missing canonical command accepted' } +if (Test-Path '__MARKER__') { throw 'canonical lookup auto-imported a module' } +# Verify the test module really can be discovered by an ordinary exact lookup. +Get-Command -Name sp | Out-Null +if (-not (Test-Path '__MARKER__')) { throw 'autoload fixture was ineffective' } +exit 0 +""".replace("__MODULES__", str(modules).replace("'", "''")) + .replace("__ASSET__", str(ASSETS / "sp.ps1").replace("'", "''")) + .replace("__MARKER__", str(marker).replace("'", "''")) + .replace("__CANONICAL__", str(self.bin / ("session-peer.cmd" if os.name == "nt" else "session-peer")).replace("'", "''")), encoding="utf-8-sig") + result = subprocess.run([shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)], + env=self.env, input="", capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + @unittest.skipUnless(os.name == "nt" or shutil.which("pwsh"), "PowerShell not installed") def test_powershell_actual_cli_equivalence(self): shell = shutil.which("powershell") if os.name == "nt" else shutil.which("pwsh") From 91f052546275797cd1fe23badb1d7d0ce61dffdf Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:20:59 +0900 Subject: [PATCH 6/7] fix(cli): keep native launcher discovery without module autoload --- session_peer_shorthand/sp.ps1 | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/session_peer_shorthand/sp.ps1 b/session_peer_shorthand/sp.ps1 index 08fb6a62..fb82d2ff 100644 --- a/session_peer_shorthand/sp.ps1 +++ b/session_peer_shorthand/sp.ps1 @@ -4,9 +4,13 @@ param([Alias('Remove')][switch] $SessionPeerSpRemove) if ($MyInvocation.InvocationName -ne '.') { throw 'Dot-source sp.ps1 to change the current PowerShell scope.' } -# Wildcard syntax forces enumeration rather than exact-name command discovery. # -ListImported alone still permits exact-name misses to auto-import a module. -$SessionPeerSpExisting = @(Get-Command -Name '[s]p' -All -ListImported -ErrorAction SilentlyContinue) +# Disable autoload only in a child lookup scope, preserving caller preferences. +# Exact-name lookup still finds native executables and Windows .cmd launchers. +$SessionPeerSpExisting = @(& { + $PSModuleAutoLoadingPreference = 'None' + Get-Command -Name sp -All -ListImported -ErrorAction SilentlyContinue +}) $SessionPeerSpMarker = Get-Variable -Name SessionPeerSpOwned -Scope Local -ErrorAction SilentlyContinue $SessionPeerSpVisibleAlias = Get-Alias -Name sp -ErrorAction SilentlyContinue if ($SessionPeerSpVisibleAlias -and $SessionPeerSpMarker -and $SessionPeerSpMarker.Value -eq $true -and @@ -24,7 +28,11 @@ if ($SessionPeerSpRemove) { Remove-Variable -Name SessionPeerSpOwned -ErrorAction SilentlyContinue return } -if (-not (Get-Command -Name '[s]ession-peer' -ListImported -ErrorAction SilentlyContinue)) { +$SessionPeerSpCanonical = @(& { + $PSModuleAutoLoadingPreference = 'None' + Get-Command -Name session-peer -ListImported -ErrorAction SilentlyContinue +}) +if ($SessionPeerSpCanonical.Count -eq 0) { throw 'session-peer is unavailable; select its existing installation on PATH first.' } Set-Alias -Name sp -Value session-peer -Scope Local -ErrorAction Stop From 4f2446dfbe77b43aea64e9a9483e396100f505ea Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:23:58 +0900 Subject: [PATCH 7/7] fix(cli): restore caller autoload preference after passive lookup --- session_peer_shorthand/sp.ps1 | 23 ++++++++++++++--------- tests/distribution/test_shorthand.py | 3 +++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/session_peer_shorthand/sp.ps1 b/session_peer_shorthand/sp.ps1 index fb82d2ff..73d476b0 100644 --- a/session_peer_shorthand/sp.ps1 +++ b/session_peer_shorthand/sp.ps1 @@ -5,12 +5,21 @@ if ($MyInvocation.InvocationName -ne '.') { throw 'Dot-source sp.ps1 to change the current PowerShell scope.' } # -ListImported alone still permits exact-name misses to auto-import a module. -# Disable autoload only in a child lookup scope, preserving caller preferences. +# Disable autoload only during lookup, restoring the caller's local preference. # Exact-name lookup still finds native executables and Windows .cmd launchers. -$SessionPeerSpExisting = @(& { - $PSModuleAutoLoadingPreference = 'None' - Get-Command -Name sp -All -ListImported -ErrorAction SilentlyContinue -}) +$SessionPeerSpPreference = Get-Variable -Name PSModuleAutoLoadingPreference -Scope Local -ErrorAction SilentlyContinue +$SessionPeerSpSavedPreference = if ($SessionPeerSpPreference) { $SessionPeerSpPreference.Value } else { $null } +try { + Set-Variable -Name PSModuleAutoLoadingPreference -Value 'None' -Scope Local -ErrorAction Stop + $SessionPeerSpExisting = @(Get-Command -Name sp -All -ListImported -ErrorAction SilentlyContinue) + $SessionPeerSpCanonical = @(Get-Command -Name session-peer -ListImported -ErrorAction SilentlyContinue) +} finally { + if ($SessionPeerSpPreference) { + Set-Variable -Name PSModuleAutoLoadingPreference -Value $SessionPeerSpSavedPreference -Scope Local -ErrorAction Stop + } else { + Remove-Variable -Name PSModuleAutoLoadingPreference -Scope Local -ErrorAction SilentlyContinue + } +} $SessionPeerSpMarker = Get-Variable -Name SessionPeerSpOwned -Scope Local -ErrorAction SilentlyContinue $SessionPeerSpVisibleAlias = Get-Alias -Name sp -ErrorAction SilentlyContinue if ($SessionPeerSpVisibleAlias -and $SessionPeerSpMarker -and $SessionPeerSpMarker.Value -eq $true -and @@ -28,10 +37,6 @@ if ($SessionPeerSpRemove) { Remove-Variable -Name SessionPeerSpOwned -ErrorAction SilentlyContinue return } -$SessionPeerSpCanonical = @(& { - $PSModuleAutoLoadingPreference = 'None' - Get-Command -Name session-peer -ListImported -ErrorAction SilentlyContinue -}) if ($SessionPeerSpCanonical.Count -eq 0) { throw 'session-peer is unavailable; select its existing installation on PATH first.' } diff --git a/tests/distribution/test_shorthand.py b/tests/distribution/test_shorthand.py index 3cb079b9..e4cc923f 100644 --- a/tests/distribution/test_shorthand.py +++ b/tests/distribution/test_shorthand.py @@ -243,13 +243,16 @@ def test_powershell_collision_lookup_never_autoloads_modules(self): Remove-Item Alias:sp -Force -ErrorAction SilentlyContinue $env:PSModulePath = '__MODULES__' + [IO.Path]::PathSeparator + $env:PSModulePath $asset = '__ASSET__' +$PSModuleAutoLoadingPreference = 'All' . $asset +if ($PSModuleAutoLoadingPreference -ne 'All') { throw 'caller autoload preference changed' } if (Test-Path '__MARKER__') { throw 'sp lookup auto-imported a module' } . $asset -Remove Remove-Item '__CANONICAL__' $refused = $false try { . $asset } catch { $refused = $true } if (-not $refused) { throw 'missing canonical command accepted' } +if ($PSModuleAutoLoadingPreference -ne 'All') { throw 'refusal changed caller autoload preference' } if (Test-Path '__MARKER__') { throw 'canonical lookup auto-imported a module' } # Verify the test module really can be discovered by an ordinary exact lookup. Get-Command -Name sp | Out-Null