fix(rebrand): preserve external contracts
Keep required Codex identity metadata, negotiate extension protocol v2, make tagged release retries idempotent, and align the first-release installer examples.
- commit
- 6bb33e50d0d25ec9e9a5b1871b9d928dd9b8057c
- author
- brice · · 2026-08-05 16:09 UTC
- parent
- e4a06c5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +248 -91
A .github/scripts/next-release-tag.sh +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Print the release tag for HEAD, or nothing when its unreleased commits do not | |
| 3 | + | # require a release. A tag already pointing to HEAD is emitted again so a | |
| 4 | + | # failed GoReleaser job can be retried without minting a second tag. | |
| 5 | + | set -euo pipefail | |
| 6 | + | ||
| 7 | + | latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1) | |
| 8 | + | head=$(git rev-parse HEAD) | |
| 9 | + | if [ -n "$latest" ] && [ "$(git rev-parse "$latest^{}")" = "$head" ]; then | |
| 10 | + | printf '%s\n' "$latest" | |
| 11 | + | exit 0 | |
| 12 | + | fi | |
| 13 | + | ||
| 14 | + | if [ -n "$latest" ]; then | |
| 15 | + | range="${latest}..HEAD" | |
| 16 | + | else | |
| 17 | + | range="HEAD" | |
| 18 | + | fi | |
| 19 | + | ||
| 20 | + | minor=false | |
| 21 | + | patch=false | |
| 22 | + | while IFS= read -r commit; do | |
| 23 | + | subject=$(git log -1 --format=%s "$commit") | |
| 24 | + | body=$(git log -1 --format=%b "$commit") | |
| 25 | + | ||
| 26 | + | # A breaking change is a v0.x minor release. | |
| 27 | + | if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then | |
| 28 | + | minor=true | |
| 29 | + | continue | |
| 30 | + | fi | |
| 31 | + | ||
| 32 | + | type=${subject%%(*} | |
| 33 | + | type=${type%%:*} | |
| 34 | + | type=${type%%!*} | |
| 35 | + | type=$(tr '[:upper:]' '[:lower:]' <<<"$type") | |
| 36 | + | case "$type" in | |
| 37 | + | feat) minor=true ;; | |
| 38 | + | fix|perf|refactor|build|chore|ci) patch=true ;; | |
| 39 | + | docs|test|style) ;; | |
| 40 | + | *) ;; | |
| 41 | + | esac | |
| 42 | + | done < <(git rev-list "$range") | |
| 43 | + | ||
| 44 | + | if [ "$minor" != true ] && [ "$patch" != true ]; then | |
| 45 | + | exit 0 | |
| 46 | + | fi | |
| 47 | + | ||
| 48 | + | # The renamed project starts a new public release line at v0.1.0. | |
| 49 | + | if [ -z "$latest" ]; then | |
| 50 | + | printf '%s\n' 'v0.1.0' | |
| 51 | + | exit 0 | |
| 52 | + | fi | |
| 53 | + | ||
| 54 | + | version=${latest#v} | |
| 55 | + | IFS=. read -r major minor_version patch_version <<<"$version" | |
| 56 | + | if [ "$minor" = true ]; then | |
| 57 | + | printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))" | |
| 58 | + | else | |
| 59 | + | printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))" | |
| 60 | + | fi | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | # Print the release tag for HEAD, or nothing when its unreleased commits do not | |||
| 3 | + | # require a release. A tag already pointing to HEAD is emitted again so a | |||
| 4 | + | # failed GoReleaser job can be retried without minting a second tag. | |||
| 5 | + | set -euo pipefail | |||
| 6 | + | ||||
| 7 | + | latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1) | |||
| 8 | + | head=$(git rev-parse HEAD) | |||
| 9 | + | if [ -n "$latest" ] && [ "$(git rev-parse "$latest^{}")" = "$head" ]; then | |||
| 10 | + | printf '%s\n' "$latest" | |||
| 11 | + | exit 0 | |||
| 12 | + | fi | |||
| 13 | + | ||||
| 14 | + | if [ -n "$latest" ]; then | |||
| 15 | + | range="${latest}..HEAD" | |||
| 16 | + | else | |||
| 17 | + | range="HEAD" | |||
| 18 | + | fi | |||
| 19 | + | ||||
| 20 | + | minor=false | |||
| 21 | + | patch=false | |||
| 22 | + | while IFS= read -r commit; do | |||
| 23 | + | subject=$(git log -1 --format=%s "$commit") | |||
| 24 | + | body=$(git log -1 --format=%b "$commit") | |||
| 25 | + | ||||
| 26 | + | # A breaking change is a v0.x minor release. | |||
| 27 | + | if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then | |||
| 28 | + | minor=true | |||
| 29 | + | continue | |||
| 30 | + | fi | |||
| 31 | + | ||||
| 32 | + | type=${subject%%(*} | |||
| 33 | + | type=${type%%:*} | |||
| 34 | + | type=${type%%!*} | |||
| 35 | + | type=$(tr '[:upper:]' '[:lower:]' <<<"$type") | |||
| 36 | + | case "$type" in | |||
| 37 | + | feat) minor=true ;; | |||
| 38 | + | fix|perf|refactor|build|chore|ci) patch=true ;; | |||
| 39 | + | docs|test|style) ;; | |||
| 40 | + | *) ;; | |||
| 41 | + | esac | |||
| 42 | + | done < <(git rev-list "$range") | |||
| 43 | + | ||||
| 44 | + | if [ "$minor" != true ] && [ "$patch" != true ]; then | |||
| 45 | + | exit 0 | |||
| 46 | + | fi | |||
| 47 | + | ||||
| 48 | + | # The renamed project starts a new public release line at v0.1.0. | |||
| 49 | + | if [ -z "$latest" ]; then | |||
| 50 | + | printf '%s\n' 'v0.1.0' | |||
| 51 | + | exit 0 | |||
| 52 | + | fi | |||
| 53 | + | ||||
| 54 | + | version=${latest#v} | |||
| 55 | + | IFS=. read -r major minor_version patch_version <<<"$version" | |||
| 56 | + | if [ "$minor" = true ]; then | |||
| 57 | + | printf 'v%s.%s.0\n' "$major" "$((minor_version + 1))" | |||
| 58 | + | else | |||
| 59 | + | printf 'v%s.%s.%s\n' "$major" "$minor_version" "$((patch_version + 1))" | |||
| 60 | + | fi | |||
A .github/scripts/next-release-tag_test.sh +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | set -euo pipefail | |
| 3 | + | ||
| 4 | + | repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) | |
| 5 | + | script="$repo_root/.github/scripts/next-release-tag.sh" | |
| 6 | + | tmp=$(mktemp -d) | |
| 7 | + | trap 'rm -rf "$tmp"' EXIT | |
| 8 | + | ||
| 9 | + | cd "$tmp" | |
| 10 | + | git init --quiet | |
| 11 | + | git config user.name 'Release Test' | |
| 12 | + | git config user.email 'release-test@example.invalid' | |
| 13 | + | ||
| 14 | + | git commit --allow-empty --quiet -m 'feat: first release' | |
| 15 | + | expect() { | |
| 16 | + | local want=$1 | |
| 17 | + | local got | |
| 18 | + | got=$($script) | |
| 19 | + | if [ "$got" != "$want" ]; then | |
| 20 | + | printf 'next-release-tag = %q, want %q\n' "$got" "$want" >&2 | |
| 21 | + | exit 1 | |
| 22 | + | fi | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | expect 'v0.1.0' | |
| 26 | + | git tag -a v0.1.0 -m 'release v0.1.0' | |
| 27 | + | ||
| 28 | + | # A rerun after tag creation must release the existing tag, not skip it. | |
| 29 | + | expect 'v0.1.0' | |
| 30 | + | ||
| 31 | + | git commit --allow-empty --quiet -m 'docs: explain releases' | |
| 32 | + | expect '' | |
| 33 | + | ||
| 34 | + | git commit --allow-empty --quiet -m 'fix: repair installer' | |
| 35 | + | expect 'v0.1.1' | |
| 36 | + | git tag -a v0.1.1 -m 'release v0.1.1' | |
| 37 | + | ||
| 38 | + | git commit --allow-empty --quiet -m 'refactor!: replace extension handshake' | |
| 39 | + | expect 'v0.2.0' | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | set -euo pipefail | |||
| 3 | + | ||||
| 4 | + | repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) | |||
| 5 | + | script="$repo_root/.github/scripts/next-release-tag.sh" | |||
| 6 | + | tmp=$(mktemp -d) | |||
| 7 | + | trap 'rm -rf "$tmp"' EXIT | |||
| 8 | + | ||||
| 9 | + | cd "$tmp" | |||
| 10 | + | git init --quiet | |||
| 11 | + | git config user.name 'Release Test' | |||
| 12 | + | git config user.email 'release-test@example.invalid' | |||
| 13 | + | ||||
| 14 | + | git commit --allow-empty --quiet -m 'feat: first release' | |||
| 15 | + | expect() { | |||
| 16 | + | local want=$1 | |||
| 17 | + | local got | |||
| 18 | + | got=$($script) | |||
| 19 | + | if [ "$got" != "$want" ]; then | |||
| 20 | + | printf 'next-release-tag = %q, want %q\n' "$got" "$want" >&2 | |||
| 21 | + | exit 1 | |||
| 22 | + | fi | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | expect 'v0.1.0' | |||
| 26 | + | git tag -a v0.1.0 -m 'release v0.1.0' | |||
| 27 | + | ||||
| 28 | + | # A rerun after tag creation must release the existing tag, not skip it. | |||
| 29 | + | expect 'v0.1.0' | |||
| 30 | + | ||||
| 31 | + | git commit --allow-empty --quiet -m 'docs: explain releases' | |||
| 32 | + | expect '' | |||
| 33 | + | ||||
| 34 | + | git commit --allow-empty --quiet -m 'fix: repair installer' | |||
| 35 | + | expect 'v0.1.1' | |||
| 36 | + | git tag -a v0.1.1 -m 'release v0.1.1' | |||
| 37 | + | ||||
| 38 | + | git commit --allow-empty --quiet -m 'refactor!: replace extension handshake' | |||
| 39 | + | expect 'v0.2.0' | |||
M .github/workflows/ci.yml +3 -0
| @@ -53,6 +53,9 @@ jobs: | |||
| 53 | 53 | steps: | |
| 54 | 54 | - uses: actions/checkout@v4 | |
| 55 | 55 | ||
| 56 | + | - name: release-version policy | |
| 57 | + | run: bash .github/scripts/next-release-tag_test.sh | |
| 58 | + | ||
| 56 | 59 | - name: set up go | |
| 57 | 60 | uses: actions/setup-go@v7 | |
| 58 | 61 | with: | |
| @@ -53,6 +53,9 @@ jobs: | |||||
| 53 | steps: | 53 | steps: | ||
| 54 | - uses: actions/checkout@v4 | 54 | - uses: actions/checkout@v4 | ||
| 55 | 55 | ||||
| 56 | + | - name: release-version policy | |||
| 57 | + | run: bash .github/scripts/next-release-tag_test.sh | |||
| 58 | + | ||||
| 56 | - name: set up go | 59 | - name: set up go | ||
| 57 | uses: actions/setup-go@v7 | 60 | uses: actions/setup-go@v7 | ||
| 58 | with: | 61 | with: | ||
M .github/workflows/release.yml +5 -50
| @@ -38,58 +38,13 @@ jobs: | |||
| 38 | 38 | shell: bash | |
| 39 | 39 | run: | | |
| 40 | 40 | set -euo pipefail | |
| 41 | - | ||
| 42 | - | latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1) | |
| 43 | - | if [ -n "$latest" ]; then | |
| 44 | - | range="${latest}..HEAD" | |
| 45 | - | else | |
| 46 | - | range="HEAD" | |
| 47 | - | fi | |
| 48 | - | ||
| 49 | - | minor=false | |
| 50 | - | patch=false | |
| 51 | - | while IFS= read -r commit; do | |
| 52 | - | subject=$(git log -1 --format=%s "$commit") | |
| 53 | - | body=$(git log -1 --format=%b "$commit") | |
| 54 | - | ||
| 55 | - | # A breaking change is a v0.x minor release. | |
| 56 | - | if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then | |
| 57 | - | minor=true | |
| 58 | - | continue | |
| 59 | - | fi | |
| 60 | - | ||
| 61 | - | type=${subject%%(*} | |
| 62 | - | type=${type%%:*} | |
| 63 | - | type=${type%%!*} | |
| 64 | - | type=$(tr '[:upper:]' '[:lower:]' <<<"$type") | |
| 65 | - | case "$type" in | |
| 66 | - | feat) minor=true ;; | |
| 67 | - | fix|perf|refactor|build|chore|ci) patch=true ;; | |
| 68 | - | docs|test|style) ;; | |
| 69 | - | *) ;; | |
| 70 | - | esac | |
| 71 | - | done < <(git rev-list "$range") | |
| 72 | - | ||
| 73 | - | if [ "$minor" != true ] && [ "$patch" != true ]; then | |
| 74 | - | echo "No releasable conventional commits since ${latest:-the repository start}." | |
| 41 | + | tag=$(bash .github/scripts/next-release-tag.sh) | |
| 42 | + | if [ -z "$tag" ]; then | |
| 43 | + | echo "No releasable conventional commits for this run." | |
| 75 | 44 | exit 0 | |
| 76 | 45 | fi | |
| 77 | - | ||
| 78 | - | # The renamed project starts a new public release line at v0.1.0. | |
| 79 | - | if [ -z "$latest" ]; then | |
| 80 | - | next="v0.1.0" | |
| 81 | - | else | |
| 82 | - | version=${latest#v} | |
| 83 | - | IFS=. read -r major minor_version patch_version <<<"$version" | |
| 84 | - | if [ "$minor" = true ]; then | |
| 85 | - | next="v${major}.$((minor_version + 1)).0" | |
| 86 | - | else | |
| 87 | - | next="v${major}.${minor_version}.$((patch_version + 1))" | |
| 88 | - | fi | |
| 89 | - | fi | |
| 90 | - | ||
| 91 | - | echo "Creating $next from $range" | |
| 92 | - | echo "tag=$next" >> "$GITHUB_OUTPUT" | |
| 46 | + | echo "Releasing $tag from $(git rev-parse --short HEAD)" | |
| 47 | + | echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| 93 | 48 | ||
| 94 | 49 | - name: Create and push the tag | |
| 95 | 50 | if: steps.version.outputs.tag != '' | |
| @@ -38,58 +38,13 @@ jobs: | |||||
| 38 | shell: bash | 38 | shell: bash | ||
| 39 | run: | | 39 | run: | | ||
| 40 | set -euo pipefail | 40 | set -euo pipefail | ||
| 41 | - | 41 | + | tag=$(bash .github/scripts/next-release-tag.sh) | |
| 42 | - | latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1) | 42 | + | if [ -z "$tag" ]; then |
| 43 | - | if [ -n "$latest" ]; then | 43 | + | echo "No releasable conventional commits for this run." |
| 44 | - | range="${latest}..HEAD" | |||
| 45 | - | else | |||
| 46 | - | range="HEAD" | |||
| 47 | - | fi | |||
| 48 | - | ||||
| 49 | - | minor=false | |||
| 50 | - | patch=false | |||
| 51 | - | while IFS= read -r commit; do | |||
| 52 | - | subject=$(git log -1 --format=%s "$commit") | |||
| 53 | - | body=$(git log -1 --format=%b "$commit") | |||
| 54 | - | ||||
| 55 | - | # A breaking change is a v0.x minor release. | |||
| 56 | - | if grep -Eq '^[[:alpha:]]+(\([^)]+\))?!:' <<<"$subject" || grep -Eiq '^BREAKING[ -]CHANGE:' <<<"$body"; then | |||
| 57 | - | minor=true | |||
| 58 | - | continue | |||
| 59 | - | fi | |||
| 60 | - | ||||
| 61 | - | type=${subject%%(*} | |||
| 62 | - | type=${type%%:*} | |||
| 63 | - | type=${type%%!*} | |||
| 64 | - | type=$(tr '[:upper:]' '[:lower:]' <<<"$type") | |||
| 65 | - | case "$type" in | |||
| 66 | - | feat) minor=true ;; | |||
| 67 | - | fix|perf|refactor|build|chore|ci) patch=true ;; | |||
| 68 | - | docs|test|style) ;; | |||
| 69 | - | *) ;; | |||
| 70 | - | esac | |||
| 71 | - | done < <(git rev-list "$range") | |||
| 72 | - | ||||
| 73 | - | if [ "$minor" != true ] && [ "$patch" != true ]; then | |||
| 74 | - | echo "No releasable conventional commits since ${latest:-the repository start}." | |||
| 75 | exit 0 | 44 | exit 0 | ||
| 76 | fi | 45 | fi | ||
| 77 | - | 46 | + | echo "Releasing $tag from $(git rev-parse --short HEAD)" | |
| 78 | - | # The renamed project starts a new public release line at v0.1.0. | 47 | + | echo "tag=$tag" >> "$GITHUB_OUTPUT" |
| 79 | - | if [ -z "$latest" ]; then | |||
| 80 | - | next="v0.1.0" | |||
| 81 | - | else | |||
| 82 | - | version=${latest#v} | |||
| 83 | - | IFS=. read -r major minor_version patch_version <<<"$version" | |||
| 84 | - | if [ "$minor" = true ]; then | |||
| 85 | - | next="v${major}.$((minor_version + 1)).0" | |||
| 86 | - | else | |||
| 87 | - | next="v${major}.${minor_version}.$((patch_version + 1))" | |||
| 88 | - | fi | |||
| 89 | - | fi | |||
| 90 | - | ||||
| 91 | - | echo "Creating $next from $range" | |||
| 92 | - | echo "tag=$next" >> "$GITHUB_OUTPUT" | |||
| 93 | 48 | ||||
| 94 | - name: Create and push the tag | 49 | - name: Create and push the tag | ||
| 95 | if: steps.version.outputs.tag != '' | 50 | if: steps.version.outputs.tag != '' | ||
M README.md +1 -1
| @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash | |||
| 43 | 43 | Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin: | |
| 44 | 44 | ||
| 45 | 45 | ```bash | |
| 46 | - | curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin | |
| 46 | + | curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin | |
| 47 | 47 | ``` | |
| 48 | 48 | ||
| 49 | 49 | ### One-liner (Windows, PowerShell) | |
| @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash | |||||
| 43 | Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin: | 43 | Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin: | ||
| 44 | 44 | ||||
| 45 | ```bash | 45 | ```bash | ||
| 46 | - | curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin | 46 | + | curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin |
| 47 | ``` | 47 | ``` | ||
| 48 | 48 | ||||
| 49 | ### One-liner (Windows, PowerShell) | 49 | ### One-liner (Windows, PowerShell) | ||
M docs/extensions.md +12 -8
| @@ -35,11 +35,13 @@ def emit(obj): | |||
| 35 | 35 | sys.stdout.write(json.dumps(obj) + "\n") | |
| 36 | 36 | sys.stdout.flush() | |
| 37 | 37 | ||
| 38 | - | emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]}) | |
| 38 | + | emit({"type":"hello","protocol_version":2,"name":"hello-py","version":"1.0.0","capabilities":["commands"]}) | |
| 39 | 39 | ||
| 40 | 40 | for line in sys.stdin: | |
| 41 | 41 | msg = json.loads(line) | |
| 42 | 42 | if msg["type"] == "hello_ack": | |
| 43 | + | if msg.get("protocol_version") != 2: | |
| 44 | + | raise SystemExit("unsupported extension protocol version") | |
| 43 | 45 | emit({"type":"register_command","name":"hellopy","description":"say hi (python)"}) | |
| 44 | 46 | emit({"type":"ready"}) | |
| 45 | 47 | elif msg["type"] == "command_invoked": | |
| @@ -156,9 +158,10 @@ manifest tells zut how to launch it: | |||
| 156 | 158 | redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per | |
| 157 | 159 | extension, append-mode). | |
| 158 | 160 | 3. **Hello handshake**: the extension's first stdout frame must be | |
| 159 | - | `hello`; zut replies with `hello_ack` containing the protocol | |
| 160 | - | version, the active provider/model/cwd, and the extension's own | |
| 161 | - | data directory so it can persist files beside its manifest. | |
| 161 | + | `hello` with `protocol_version: 2`; zut rejects a mismatched major | |
| 162 | + | and replies with `hello_ack` containing the same protocol version, | |
| 163 | + | the active provider/model/cwd, and the extension's own data directory | |
| 164 | + | so it can persist files beside its manifest. | |
| 162 | 165 | 4. **Registration**: after receiving `hello_ack`, the extension sends | |
| 163 | 166 | `register_command`, `register_tool`, and subscription frames, then | |
| 164 | 167 | sends `ready`. First-come-first-served: a name already taken by a | |
| @@ -193,7 +196,7 @@ or any other stdout frame before `hello`. | |||
| 193 | 196 | #### `hello` (required, first frame) | |
| 194 | 197 | ||
| 195 | 198 | ```json | |
| 196 | - | {"type":"hello","name":"weather","version":"1.0.0", | |
| 199 | + | {"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0", | |
| 197 | 200 | "capabilities":["commands","tools","alerts","panels"]} | |
| 198 | 201 | ``` | |
| 199 | 202 | ||
| @@ -498,7 +501,7 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||
| 498 | 501 | #### `hello_ack` | |
| 499 | 502 | ||
| 500 | 503 | ```json | |
| 501 | - | {"type":"hello_ack","protocol_version":1, | |
| 504 | + | {"type":"hello_ack","protocol_version":2, | |
| 502 | 505 | "zut_version":"0.0.7","provider":"anthropic", | |
| 503 | 506 | "model":"claude-opus-4-7","cwd":"/path/to/zut", | |
| 504 | 507 | "extension_dir":"/path/to/zut/.zut/extensions/todos", | |
| @@ -508,8 +511,9 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||
| 508 | 511 | "fork_point":4}} | |
| 509 | 512 | ``` | |
| 510 | 513 | ||
| 511 | - | Sent immediately after `hello`. Wait for this frame before sending | |
| 512 | - | registrations if they depend on host metadata. The extension can use | |
| 514 | + | Sent immediately after a matching `hello`. The protocol version is a | |
| 515 | + | major and must match the version sent by the extension. Wait for this frame | |
| 516 | + | before sending registrations if they depend on host metadata. The extension can use | |
| 513 | 517 | these fields to decide which commands to register (e.g. only register | |
| 514 | 518 | a Python tool on macOS, only register a model-specific shortcut for | |
| 515 | 519 | opus, etc.). `cwd` is the user's project directory; the extension | |
| @@ -35,11 +35,13 @@ def emit(obj): | |||||
| 35 | sys.stdout.write(json.dumps(obj) + "\n") | 35 | sys.stdout.write(json.dumps(obj) + "\n") | ||
| 36 | sys.stdout.flush() | 36 | sys.stdout.flush() | ||
| 37 | 37 | ||||
| 38 | - | emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]}) | 38 | + | emit({"type":"hello","protocol_version":2,"name":"hello-py","version":"1.0.0","capabilities":["commands"]}) |
| 39 | 39 | ||||
| 40 | for line in sys.stdin: | 40 | for line in sys.stdin: | ||
| 41 | msg = json.loads(line) | 41 | msg = json.loads(line) | ||
| 42 | if msg["type"] == "hello_ack": | 42 | if msg["type"] == "hello_ack": | ||
| 43 | + | if msg.get("protocol_version") != 2: | |||
| 44 | + | raise SystemExit("unsupported extension protocol version") | |||
| 43 | emit({"type":"register_command","name":"hellopy","description":"say hi (python)"}) | 45 | emit({"type":"register_command","name":"hellopy","description":"say hi (python)"}) | ||
| 44 | emit({"type":"ready"}) | 46 | emit({"type":"ready"}) | ||
| 45 | elif msg["type"] == "command_invoked": | 47 | elif msg["type"] == "command_invoked": | ||
| @@ -156,9 +158,10 @@ manifest tells zut how to launch it: | |||||
| 156 | redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per | 158 | redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per | ||
| 157 | extension, append-mode). | 159 | extension, append-mode). | ||
| 158 | 3. **Hello handshake**: the extension's first stdout frame must be | 160 | 3. **Hello handshake**: the extension's first stdout frame must be | ||
| 159 | - | `hello`; zut replies with `hello_ack` containing the protocol | 161 | + | `hello` with `protocol_version: 2`; zut rejects a mismatched major |
| 160 | - | version, the active provider/model/cwd, and the extension's own | 162 | + | and replies with `hello_ack` containing the same protocol version, |
| 161 | - | data directory so it can persist files beside its manifest. | 163 | + | the active provider/model/cwd, and the extension's own data directory |
| 164 | + | so it can persist files beside its manifest. | |||
| 162 | 4. **Registration**: after receiving `hello_ack`, the extension sends | 165 | 4. **Registration**: after receiving `hello_ack`, the extension sends | ||
| 163 | `register_command`, `register_tool`, and subscription frames, then | 166 | `register_command`, `register_tool`, and subscription frames, then | ||
| 164 | sends `ready`. First-come-first-served: a name already taken by a | 167 | sends `ready`. First-come-first-served: a name already taken by a | ||
| @@ -193,7 +196,7 @@ or any other stdout frame before `hello`. | |||||
| 193 | #### `hello` (required, first frame) | 196 | #### `hello` (required, first frame) | ||
| 194 | 197 | ||||
| 195 | ```json | 198 | ```json | ||
| 196 | - | {"type":"hello","name":"weather","version":"1.0.0", | 199 | + | {"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0", |
| 197 | "capabilities":["commands","tools","alerts","panels"]} | 200 | "capabilities":["commands","tools","alerts","panels"]} | ||
| 198 | ``` | 201 | ``` | ||
| 199 | 202 | ||||
| @@ -498,7 +501,7 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||||
| 498 | #### `hello_ack` | 501 | #### `hello_ack` | ||
| 499 | 502 | ||||
| 500 | ```json | 503 | ```json | ||
| 501 | - | {"type":"hello_ack","protocol_version":1, | 504 | + | {"type":"hello_ack","protocol_version":2, |
| 502 | "zut_version":"0.0.7","provider":"anthropic", | 505 | "zut_version":"0.0.7","provider":"anthropic", | ||
| 503 | "model":"claude-opus-4-7","cwd":"/path/to/zut", | 506 | "model":"claude-opus-4-7","cwd":"/path/to/zut", | ||
| 504 | "extension_dir":"/path/to/zut/.zut/extensions/todos", | 507 | "extension_dir":"/path/to/zut/.zut/extensions/todos", | ||
| @@ -508,8 +511,9 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||||
| 508 | "fork_point":4}} | 511 | "fork_point":4}} | ||
| 509 | ``` | 512 | ``` | ||
| 510 | 513 | ||||
| 511 | - | Sent immediately after `hello`. Wait for this frame before sending | 514 | + | Sent immediately after a matching `hello`. The protocol version is a |
| 512 | - | registrations if they depend on host metadata. The extension can use | 515 | + | major and must match the version sent by the extension. Wait for this frame |
| 516 | + | before sending registrations if they depend on host metadata. The extension can use | |||
| 513 | these fields to decide which commands to register (e.g. only register | 517 | these fields to decide which commands to register (e.g. only register | ||
| 514 | a Python tool on macOS, only register a model-specific shortcut for | 518 | a Python tool on macOS, only register a model-specific shortcut for | ||
| 515 | opus, etc.). `cwd` is the user's project directory; the extension | 519 | opus, etc.). `cwd` is the user's project directory; the extension | ||
M examples/extensions/clock/index.js +7 -0
| @@ -52,6 +52,7 @@ function log(msg) { | |||
| 52 | 52 | // 1. Hello first. | |
| 53 | 53 | send({ | |
| 54 | 54 | type: "hello", | |
| 55 | + | protocol_version: 2, | |
| 55 | 56 | name: NAME, | |
| 56 | 57 | version: VERSION, | |
| 57 | 58 | capabilities: ["commands"], | |
| @@ -84,6 +85,12 @@ rl.on("line", (line) => { | |||
| 84 | 85 | ||
| 85 | 86 | switch (frame.type) { | |
| 86 | 87 | case "hello_ack": | |
| 88 | + | if (frame.protocol_version !== 2) { | |
| 89 | + | log(`unsupported protocol version: ${frame.protocol_version}`); | |
| 90 | + | process.exitCode = 1; | |
| 91 | + | rl.close(); | |
| 92 | + | break; | |
| 93 | + | } | |
| 87 | 94 | log( | |
| 88 | 95 | `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`, | |
| 89 | 96 | ); | |
| @@ -52,6 +52,7 @@ function log(msg) { | |||||
| 52 | // 1. Hello first. | 52 | // 1. Hello first. | ||
| 53 | send({ | 53 | send({ | ||
| 54 | type: "hello", | 54 | type: "hello", | ||
| 55 | + | protocol_version: 2, | |||
| 55 | name: NAME, | 56 | name: NAME, | ||
| 56 | version: VERSION, | 57 | version: VERSION, | ||
| 57 | capabilities: ["commands"], | 58 | capabilities: ["commands"], | ||
| @@ -84,6 +85,12 @@ rl.on("line", (line) => { | |||||
| 84 | 85 | ||||
| 85 | switch (frame.type) { | 86 | switch (frame.type) { | ||
| 86 | case "hello_ack": | 87 | case "hello_ack": | ||
| 88 | + | if (frame.protocol_version !== 2) { | |||
| 89 | + | log(`unsupported protocol version: ${frame.protocol_version}`); | |||
| 90 | + | process.exitCode = 1; | |||
| 91 | + | rl.close(); | |||
| 92 | + | break; | |||
| 93 | + | } | |||
| 87 | log( | 94 | log( | ||
| 88 | `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`, | 95 | `connected to zut ${frame.zut_version} (${frame.provider}/${frame.model})`, | ||
| 89 | ); | 96 | ); | ||
M examples/extensions/scratchpad/index.ts +7 -0
| @@ -177,6 +177,7 @@ function renderNotes(): string { | |||
| 177 | 177 | ||
| 178 | 178 | send({ | |
| 179 | 179 | type: "hello", | |
| 180 | + | protocol_version: 2, | |
| 180 | 181 | name: NAME, | |
| 181 | 182 | version: VERSION, | |
| 182 | 183 | capabilities: ["commands", "tools"], | |
| @@ -251,6 +252,12 @@ rl.on("close", () => { | |||
| 251 | 252 | }); | |
| 252 | 253 | ||
| 253 | 254 | function handleHelloAck(ack: HelloAck): void { | |
| 255 | + | if (ack.protocol_version !== 2) { | |
| 256 | + | log(`unsupported protocol version: ${ack.protocol_version}`); | |
| 257 | + | process.exitCode = 1; | |
| 258 | + | rl.close(); | |
| 259 | + | return; | |
| 260 | + | } | |
| 254 | 261 | log( | |
| 255 | 262 | `connected to zut ${ack.zut_version} ` + | |
| 256 | 263 | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | |
| @@ -177,6 +177,7 @@ function renderNotes(): string { | |||||
| 177 | 177 | ||||
| 178 | send({ | 178 | send({ | ||
| 179 | type: "hello", | 179 | type: "hello", | ||
| 180 | + | protocol_version: 2, | |||
| 180 | name: NAME, | 181 | name: NAME, | ||
| 181 | version: VERSION, | 182 | version: VERSION, | ||
| 182 | capabilities: ["commands", "tools"], | 183 | capabilities: ["commands", "tools"], | ||
| @@ -251,6 +252,12 @@ rl.on("close", () => { | |||||
| 251 | }); | 252 | }); | ||
| 252 | 253 | ||||
| 253 | function handleHelloAck(ack: HelloAck): void { | 254 | function handleHelloAck(ack: HelloAck): void { | ||
| 255 | + | if (ack.protocol_version !== 2) { | |||
| 256 | + | log(`unsupported protocol version: ${ack.protocol_version}`); | |||
| 257 | + | process.exitCode = 1; | |||
| 258 | + | rl.close(); | |||
| 259 | + | return; | |||
| 260 | + | } | |||
| 254 | log( | 261 | log( | ||
| 255 | `connected to zut ${ack.zut_version} ` + | 262 | `connected to zut ${ack.zut_version} ` + | ||
| 256 | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | 263 | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | ||
M install.ps1 +1 -1
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex | |
| 5 | 5 | # | |
| 6 | 6 | # Or with arguments: | |
| 7 | - | # $env:ZUT_VERSION = "v0.0.1" | |
| 7 | + | # $env:ZUT_VERSION = "v0.1.0" | |
| 8 | 8 | # $env:ZUT_PREFIX = "$HOME\bin" | |
| 9 | 9 | # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex | |
| 10 | 10 | # | |
| @@ -4,7 +4,7 @@ | |||||
| 4 | # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex | 4 | # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex | ||
| 5 | # | 5 | # | ||
| 6 | # Or with arguments: | 6 | # Or with arguments: | ||
| 7 | - | # $env:ZUT_VERSION = "v0.0.1" | 7 | + | # $env:ZUT_VERSION = "v0.1.0" |
| 8 | # $env:ZUT_PREFIX = "$HOME\bin" | 8 | # $env:ZUT_PREFIX = "$HOME\bin" | ||
| 9 | # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex | 9 | # iwr -useb https://raw.githubusercontent.com/bnema/zut/main/install.ps1 | iex | ||
| 10 | # | 10 | # | ||
M install.sh +2 -2
| @@ -4,10 +4,10 @@ | |||
| 4 | 4 | # | |
| 5 | 5 | # Usage: | |
| 6 | 6 | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash | |
| 7 | - | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin | |
| 7 | + | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin | |
| 8 | 8 | # | |
| 9 | 9 | # Positional arguments: | |
| 10 | - | # $1 version — release tag (e.g. v0.0.1). Defaults to "latest". | |
| 10 | + | # $1 version — release tag (e.g. v0.1.0). Defaults to "latest". | |
| 11 | 11 | # $2 prefix — install directory. Defaults to the first writable | |
| 12 | 12 | # directory in: /usr/local/bin, $HOME/.local/bin, | |
| 13 | 13 | # $HOME/bin. Created if missing. Add it to your PATH | |
| @@ -4,10 +4,10 @@ | |||||
| 4 | # | 4 | # | ||
| 5 | # Usage: | 5 | # Usage: | ||
| 6 | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash | 6 | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash | ||
| 7 | - | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.0.1 ~/bin | 7 | + | # curl -fsSL https://raw.githubusercontent.com/bnema/zut/main/install.sh | bash -s -- v0.1.0 ~/bin |
| 8 | # | 8 | # | ||
| 9 | # Positional arguments: | 9 | # Positional arguments: | ||
| 10 | - | # $1 version — release tag (e.g. v0.0.1). Defaults to "latest". | 10 | + | # $1 version — release tag (e.g. v0.1.0). Defaults to "latest". |
| 11 | # $2 prefix — install directory. Defaults to the first writable | 11 | # $2 prefix — install directory. Defaults to the first writable | ||
| 12 | # directory in: /usr/local/bin, $HOME/.local/bin, | 12 | # directory in: /usr/local/bin, $HOME/.local/bin, | ||
| 13 | # $HOME/bin. Created if missing. Add it to your PATH | 13 | # $HOME/bin. Created if missing. Add it to your PATH | ||
M packages/agent/ext/ext.go +8 -4
| @@ -559,10 +559,11 @@ func (e *Extension) Run() error { | |||
| 559 | 559 | // synchronously so extensions can use HostInfo while registering | |
| 560 | 560 | // commands/tools before the ready frame. | |
| 561 | 561 | if err := e.send(extproto.HelloFromExt{ | |
| 562 | - | Type: "hello", | |
| 563 | - | Name: e.name, | |
| 564 | - | Version: e.version, | |
| 565 | - | Capabilities: e.caps, | |
| 562 | + | Type: "hello", | |
| 563 | + | ProtocolVersion: extproto.ProtocolVersion, | |
| 564 | + | Name: e.name, | |
| 565 | + | Version: e.version, | |
| 566 | + | Capabilities: e.caps, | |
| 566 | 567 | }); err != nil { | |
| 567 | 568 | return err | |
| 568 | 569 | } | |
| @@ -576,6 +577,9 @@ func (e *Extension) Run() error { | |||
| 576 | 577 | if ack.Type != "hello_ack" { | |
| 577 | 578 | return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) | |
| 578 | 579 | } | |
| 580 | + | if ack.ProtocolVersion != extproto.ProtocolVersion { | |
| 581 | + | return fmt.Errorf("unsupported host protocol version %d (want %d)", ack.ProtocolVersion, extproto.ProtocolVersion) | |
| 582 | + | } | |
| 579 | 583 | e.host = HostInfo{ | |
| 580 | 584 | ProtocolVersion: ack.ProtocolVersion, | |
| 581 | 585 | ZutVersion: ack.ZutVersion, | |
| @@ -559,10 +559,11 @@ func (e *Extension) Run() error { | |||||
| 559 | // synchronously so extensions can use HostInfo while registering | 559 | // synchronously so extensions can use HostInfo while registering | ||
| 560 | // commands/tools before the ready frame. | 560 | // commands/tools before the ready frame. | ||
| 561 | if err := e.send(extproto.HelloFromExt{ | 561 | if err := e.send(extproto.HelloFromExt{ | ||
| 562 | - | Type: "hello", | 562 | + | Type: "hello", |
| 563 | - | Name: e.name, | 563 | + | ProtocolVersion: extproto.ProtocolVersion, |
| 564 | - | Version: e.version, | 564 | + | Name: e.name, |
| 565 | - | Capabilities: e.caps, | 565 | + | Version: e.version, |
| 566 | + | Capabilities: e.caps, | |||
| 566 | }); err != nil { | 567 | }); err != nil { | ||
| 567 | return err | 568 | return err | ||
| 568 | } | 569 | } | ||
| @@ -576,6 +577,9 @@ func (e *Extension) Run() error { | |||||
| 576 | if ack.Type != "hello_ack" { | 577 | if ack.Type != "hello_ack" { | ||
| 577 | return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) | 578 | return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) | ||
| 578 | } | 579 | } | ||
| 580 | + | if ack.ProtocolVersion != extproto.ProtocolVersion { | |||
| 581 | + | return fmt.Errorf("unsupported host protocol version %d (want %d)", ack.ProtocolVersion, extproto.ProtocolVersion) | |||
| 582 | + | } | |||
| 579 | e.host = HostInfo{ | 583 | e.host = HostInfo{ | ||
| 580 | ProtocolVersion: ack.ProtocolVersion, | 584 | ProtocolVersion: ack.ProtocolVersion, | ||
| 581 | ZutVersion: ack.ZutVersion, | 585 | ZutVersion: ack.ZutVersion, | ||
M packages/agent/ext/ext_test.go +28 -0
| @@ -140,6 +140,13 @@ func (h *extHarness) handshake(t *testing.T) { | |||
| 140 | 140 | if f.hdr.Type != "hello" { | |
| 141 | 141 | t.Fatalf("expected hello, got %q", f.hdr.Type) | |
| 142 | 142 | } | |
| 143 | + | var hello extproto.HelloFromExt | |
| 144 | + | if err := json.Unmarshal(f.raw, &hello); err != nil { | |
| 145 | + | t.Fatalf("unmarshal hello: %v", err) | |
| 146 | + | } | |
| 147 | + | if hello.ProtocolVersion != extproto.ProtocolVersion { | |
| 148 | + | t.Fatalf("hello protocol version = %d, want %d", hello.ProtocolVersion, extproto.ProtocolVersion) | |
| 149 | + | } | |
| 143 | 150 | h.sendToExt(t, extproto.HelloAckFromHost{ | |
| 144 | 151 | Type: "hello_ack", | |
| 145 | 152 | ProtocolVersion: extproto.ProtocolVersion, | |
| @@ -157,6 +164,27 @@ func (h *extHarness) handshake(t *testing.T) { | |||
| 157 | 164 | ||
| 158 | 165 | // ---------- tests ---------- | |
| 159 | 166 | ||
| 167 | + | func TestRunRejectsUnsupportedHostProtocolVersion(t *testing.T) { | |
| 168 | + | h := newHarness("versioned-ext") | |
| 169 | + | runDone := make(chan error, 1) | |
| 170 | + | go func() { runDone <- h.ext.Run() }() | |
| 171 | + | t.Cleanup(func() { _ = h.hostW.Close() }) | |
| 172 | + | ||
| 173 | + | if frame := h.next(t); frame.hdr.Type != "hello" { | |
| 174 | + | t.Fatalf("expected hello, got %q", frame.hdr.Type) | |
| 175 | + | } | |
| 176 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion - 1}) | |
| 177 | + | ||
| 178 | + | select { | |
| 179 | + | case err := <-runDone: | |
| 180 | + | if err == nil || !strings.Contains(err.Error(), "unsupported host protocol version") { | |
| 181 | + | t.Fatalf("Run error = %v", err) | |
| 182 | + | } | |
| 183 | + | case <-time.After(2 * time.Second): | |
| 184 | + | t.Fatal("Run did not reject an unsupported host protocol version") | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 160 | 188 | // TestOnHelloCanRegisterUsingHostInfo checks that extensions can use | |
| 161 | 189 | // host metadata before announcing their initial registrations. | |
| 162 | 190 | func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) { | |
| @@ -140,6 +140,13 @@ func (h *extHarness) handshake(t *testing.T) { | |||||
| 140 | if f.hdr.Type != "hello" { | 140 | if f.hdr.Type != "hello" { | ||
| 141 | t.Fatalf("expected hello, got %q", f.hdr.Type) | 141 | t.Fatalf("expected hello, got %q", f.hdr.Type) | ||
| 142 | } | 142 | } | ||
| 143 | + | var hello extproto.HelloFromExt | |||
| 144 | + | if err := json.Unmarshal(f.raw, &hello); err != nil { | |||
| 145 | + | t.Fatalf("unmarshal hello: %v", err) | |||
| 146 | + | } | |||
| 147 | + | if hello.ProtocolVersion != extproto.ProtocolVersion { | |||
| 148 | + | t.Fatalf("hello protocol version = %d, want %d", hello.ProtocolVersion, extproto.ProtocolVersion) | |||
| 149 | + | } | |||
| 143 | h.sendToExt(t, extproto.HelloAckFromHost{ | 150 | h.sendToExt(t, extproto.HelloAckFromHost{ | ||
| 144 | Type: "hello_ack", | 151 | Type: "hello_ack", | ||
| 145 | ProtocolVersion: extproto.ProtocolVersion, | 152 | ProtocolVersion: extproto.ProtocolVersion, | ||
| @@ -157,6 +164,27 @@ func (h *extHarness) handshake(t *testing.T) { | |||||
| 157 | 164 | ||||
| 158 | // ---------- tests ---------- | 165 | // ---------- tests ---------- | ||
| 159 | 166 | ||||
| 167 | + | func TestRunRejectsUnsupportedHostProtocolVersion(t *testing.T) { | |||
| 168 | + | h := newHarness("versioned-ext") | |||
| 169 | + | runDone := make(chan error, 1) | |||
| 170 | + | go func() { runDone <- h.ext.Run() }() | |||
| 171 | + | t.Cleanup(func() { _ = h.hostW.Close() }) | |||
| 172 | + | ||||
| 173 | + | if frame := h.next(t); frame.hdr.Type != "hello" { | |||
| 174 | + | t.Fatalf("expected hello, got %q", frame.hdr.Type) | |||
| 175 | + | } | |||
| 176 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion - 1}) | |||
| 177 | + | ||||
| 178 | + | select { | |||
| 179 | + | case err := <-runDone: | |||
| 180 | + | if err == nil || !strings.Contains(err.Error(), "unsupported host protocol version") { | |||
| 181 | + | t.Fatalf("Run error = %v", err) | |||
| 182 | + | } | |||
| 183 | + | case <-time.After(2 * time.Second): | |||
| 184 | + | t.Fatal("Run did not reject an unsupported host protocol version") | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 160 | // TestOnHelloCanRegisterUsingHostInfo checks that extensions can use | 188 | // TestOnHelloCanRegisterUsingHostInfo checks that extensions can use | ||
| 161 | // host metadata before announcing their initial registrations. | 189 | // host metadata before announcing their initial registrations. | ||
| 162 | func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) { | 190 | func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) { | ||
M packages/agent/extensions/intercept_test.go +2 -2
| @@ -39,7 +39,7 @@ func TestInterceptAllThreeEvents(t *testing.T) { | |||
| 39 | 39 | extDir := t.TempDir() | |
| 40 | 40 | script := `#!/bin/bash | |
| 41 | 41 | emit() { printf '%s\n' "$1"; } | |
| 42 | - | emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}' | |
| 42 | + | emit '{"type":"hello","protocol_version":2,"name":"itest","version":"0.1.0","capabilities":["events"]}' | |
| 43 | 43 | emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}' | |
| 44 | 44 | emit '{"type":"ready"}' | |
| 45 | 45 | while IFS= read -r line; do | |
| @@ -159,7 +159,7 @@ func TestReloadRespawnsExtensions(t *testing.T) { | |||
| 159 | 159 | extDir := t.TempDir() | |
| 160 | 160 | script := `#!/bin/bash | |
| 161 | 161 | emit() { printf '%s\n' "$1"; } | |
| 162 | - | emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}' | |
| 162 | + | emit '{"type":"hello","protocol_version":2,"name":"rtest","version":"0.1.0","capabilities":["commands"]}' | |
| 163 | 163 | emit '{"type":"register_command","name":"rtest","description":"test"}' | |
| 164 | 164 | emit '{"type":"ready"}' | |
| 165 | 165 | while IFS= read -r line; do | |
| @@ -39,7 +39,7 @@ func TestInterceptAllThreeEvents(t *testing.T) { | |||||
| 39 | extDir := t.TempDir() | 39 | extDir := t.TempDir() | ||
| 40 | script := `#!/bin/bash | 40 | script := `#!/bin/bash | ||
| 41 | emit() { printf '%s\n' "$1"; } | 41 | emit() { printf '%s\n' "$1"; } | ||
| 42 | - | emit '{"type":"hello","name":"itest","version":"0.1.0","capabilities":["events"]}' | 42 | + | emit '{"type":"hello","protocol_version":2,"name":"itest","version":"0.1.0","capabilities":["events"]}' |
| 43 | emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}' | 43 | emit '{"type":"subscribe","events":[],"intercept":["tool_call","turn_start","assistant_message"]}' | ||
| 44 | emit '{"type":"ready"}' | 44 | emit '{"type":"ready"}' | ||
| 45 | while IFS= read -r line; do | 45 | while IFS= read -r line; do | ||
| @@ -159,7 +159,7 @@ func TestReloadRespawnsExtensions(t *testing.T) { | |||||
| 159 | extDir := t.TempDir() | 159 | extDir := t.TempDir() | ||
| 160 | script := `#!/bin/bash | 160 | script := `#!/bin/bash | ||
| 161 | emit() { printf '%s\n' "$1"; } | 161 | emit() { printf '%s\n' "$1"; } | ||
| 162 | - | emit '{"type":"hello","name":"rtest","version":"0.1.0","capabilities":["commands"]}' | 162 | + | emit '{"type":"hello","protocol_version":2,"name":"rtest","version":"0.1.0","capabilities":["commands"]}' |
| 163 | emit '{"type":"register_command","name":"rtest","description":"test"}' | 163 | emit '{"type":"register_command","name":"rtest","description":"test"}' | ||
| 164 | emit '{"type":"ready"}' | 164 | emit '{"type":"ready"}' | ||
| 165 | while IFS= read -r line; do | 165 | while IFS= read -r line; do | ||
M packages/agent/extensions/manager.go +3 -0
| @@ -707,6 +707,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 707 | 707 | if hello.Type != "hello" || hello.Name == "" { | |
| 708 | 708 | return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath) | |
| 709 | 709 | } | |
| 710 | + | if hello.ProtocolVersion != extproto.ProtocolVersion { | |
| 711 | + | return fmt.Errorf("unsupported extension protocol version %d (want %d; stderr log: %s)", hello.ProtocolVersion, extproto.ProtocolVersion, logPath) | |
| 712 | + | } | |
| 710 | 713 | // Trust the manifest's name; ignore mismatch from the hello. | |
| 711 | 714 | ext.helloAck = true | |
| 712 | 715 | ||
| @@ -707,6 +707,9 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||||
| 707 | if hello.Type != "hello" || hello.Name == "" { | 707 | if hello.Type != "hello" || hello.Name == "" { | ||
| 708 | return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath) | 708 | return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath) | ||
| 709 | } | 709 | } | ||
| 710 | + | if hello.ProtocolVersion != extproto.ProtocolVersion { | |||
| 711 | + | return fmt.Errorf("unsupported extension protocol version %d (want %d; stderr log: %s)", hello.ProtocolVersion, extproto.ProtocolVersion, logPath) | |||
| 712 | + | } | |||
| 710 | // Trust the manifest's name; ignore mismatch from the hello. | 713 | // Trust the manifest's name; ignore mismatch from the hello. | ||
| 711 | ext.helloAck = true | 714 | ext.helloAck = true | ||
| 712 | 715 | ||||
M packages/agent/extensions/manager_test.go +35 -6
| @@ -123,7 +123,7 @@ func writeMockExtension(t *testing.T, root string) { | |||
| 123 | 123 | // stdin closes; tail's -F keeps the pipe alive long enough for | |
| 124 | 124 | // the manager to send command_invoked. | |
| 125 | 125 | script := `#!/bin/sh | |
| 126 | - | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | |
| 126 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"mock","version":"0.0.1","capabilities":["commands"]}' | |
| 127 | 127 | printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}' | |
| 128 | 128 | while IFS= read -r line; do | |
| 129 | 129 | case "$line" in | |
| @@ -260,6 +260,35 @@ func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) { | |||
| 260 | 260 | } | |
| 261 | 261 | } | |
| 262 | 262 | ||
| 263 | + | func TestSpawnRejectsUnsupportedExtensionProtocolVersion(t *testing.T) { | |
| 264 | + | if runtime.GOOS == "windows" { | |
| 265 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | tmp := t.TempDir() | |
| 269 | + | extDir := filepath.Join(tmp, "broken") | |
| 270 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 271 | + | t.Fatal(err) | |
| 272 | + | } | |
| 273 | + | script := `#!/bin/sh | |
| 274 | + | printf '%s\n' '{"type":"hello","protocol_version":1,"name":"broken"}' | |
| 275 | + | while IFS= read -r line; do :; done | |
| 276 | + | ` | |
| 277 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir} | |
| 282 | + | mgr := New(tmp, "", "0.0.0-test", "", "", nil) | |
| 283 | + | err := mgr.spawn(context.Background(), ext) | |
| 284 | + | if err == nil || !strings.Contains(err.Error(), "unsupported extension protocol version 1") { | |
| 285 | + | t.Fatalf("spawn error = %v", err) | |
| 286 | + | } | |
| 287 | + | if ext.cmd == nil || ext.cmd.ProcessState == nil { | |
| 288 | + | t.Fatal("failed protocol handshake process was not reaped") | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 263 | 292 | func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) { | |
| 264 | 293 | tmp := t.TempDir() | |
| 265 | 294 | extDir := filepath.Join(tmp, "extensions", "theme-only") | |
| @@ -423,7 +452,7 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) { | |||
| 423 | 452 | } | |
| 424 | 453 | ||
| 425 | 454 | writeDiagExtension("a-first", `#!/bin/sh | |
| 426 | - | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}' | |
| 455 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"a-first","version":"0.1","capabilities":["commands","tools"]}' | |
| 427 | 456 | printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}' | |
| 428 | 457 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | |
| 429 | 458 | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | |
| @@ -435,7 +464,7 @@ while IFS= read -r line; do | |||
| 435 | 464 | done | |
| 436 | 465 | `) | |
| 437 | 466 | writeDiagExtension("b-second", `#!/bin/sh | |
| 438 | - | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}' | |
| 467 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"b-second","version":"0.1","capabilities":["commands","tools"]}' | |
| 439 | 468 | printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}' | |
| 440 | 469 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | |
| 441 | 470 | printf '%s\n' '{"type":"ready"}' | |
| @@ -512,7 +541,7 @@ func TestSpontaneousSubmit(t *testing.T) { | |||
| 512 | 541 | } | |
| 513 | 542 | ||
| 514 | 543 | script := `#!/bin/sh | |
| 515 | - | printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}' | |
| 544 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"submit-mock","version":"0.1","capabilities":["submit"]}' | |
| 516 | 545 | printf '%s\n' '{"type":"ready"}' | |
| 517 | 546 | printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}' | |
| 518 | 547 | printf '%s\n' '{"type":"submit","text":" "}' | |
| @@ -575,7 +604,7 @@ func TestSpontaneousAlert(t *testing.T) { | |||
| 575 | 604 | } | |
| 576 | 605 | ||
| 577 | 606 | script := `#!/bin/sh | |
| 578 | - | printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}' | |
| 607 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"alert-mock","version":"0.1","capabilities":["alerts"]}' | |
| 579 | 608 | printf '%s\n' '{"type":"ready"}' | |
| 580 | 609 | printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}' | |
| 581 | 610 | while IFS= read -r line; do | |
| @@ -643,7 +672,7 @@ func TestSpontaneousOpenPanel(t *testing.T) { | |||
| 643 | 672 | // Extension emits hello + ready, then immediately fires a | |
| 644 | 673 | // spontaneous open_panel, then waits for shutdown. | |
| 645 | 674 | script := `#!/bin/sh | |
| 646 | - | printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}' | |
| 675 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"panel-mock","version":"0.1","capabilities":["panels"]}' | |
| 647 | 676 | printf '%s\n' '{"type":"ready"}' | |
| 648 | 677 | printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}' | |
| 649 | 678 | while IFS= read -r line; do | |
| @@ -123,7 +123,7 @@ func writeMockExtension(t *testing.T, root string) { | |||||
| 123 | // stdin closes; tail's -F keeps the pipe alive long enough for | 123 | // stdin closes; tail's -F keeps the pipe alive long enough for | ||
| 124 | // the manager to send command_invoked. | 124 | // the manager to send command_invoked. | ||
| 125 | script := `#!/bin/sh | 125 | script := `#!/bin/sh | ||
| 126 | - | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | 126 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"mock","version":"0.0.1","capabilities":["commands"]}' |
| 127 | printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}' | 127 | printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}' | ||
| 128 | while IFS= read -r line; do | 128 | while IFS= read -r line; do | ||
| 129 | case "$line" in | 129 | case "$line" in | ||
| @@ -260,6 +260,35 @@ func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) { | |||||
| 260 | } | 260 | } | ||
| 261 | } | 261 | } | ||
| 262 | 262 | ||||
| 263 | + | func TestSpawnRejectsUnsupportedExtensionProtocolVersion(t *testing.T) { | |||
| 264 | + | if runtime.GOOS == "windows" { | |||
| 265 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | tmp := t.TempDir() | |||
| 269 | + | extDir := filepath.Join(tmp, "broken") | |||
| 270 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |||
| 271 | + | t.Fatal(err) | |||
| 272 | + | } | |||
| 273 | + | script := `#!/bin/sh | |||
| 274 | + | printf '%s\n' '{"type":"hello","protocol_version":1,"name":"broken"}' | |||
| 275 | + | while IFS= read -r line; do :; done | |||
| 276 | + | ` | |||
| 277 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |||
| 278 | + | t.Fatal(err) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir} | |||
| 282 | + | mgr := New(tmp, "", "0.0.0-test", "", "", nil) | |||
| 283 | + | err := mgr.spawn(context.Background(), ext) | |||
| 284 | + | if err == nil || !strings.Contains(err.Error(), "unsupported extension protocol version 1") { | |||
| 285 | + | t.Fatalf("spawn error = %v", err) | |||
| 286 | + | } | |||
| 287 | + | if ext.cmd == nil || ext.cmd.ProcessState == nil { | |||
| 288 | + | t.Fatal("failed protocol handshake process was not reaped") | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 263 | func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) { | 292 | func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) { | ||
| 264 | tmp := t.TempDir() | 293 | tmp := t.TempDir() | ||
| 265 | extDir := filepath.Join(tmp, "extensions", "theme-only") | 294 | extDir := filepath.Join(tmp, "extensions", "theme-only") | ||
| @@ -423,7 +452,7 @@ func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) { | |||||
| 423 | } | 452 | } | ||
| 424 | 453 | ||||
| 425 | writeDiagExtension("a-first", `#!/bin/sh | 454 | writeDiagExtension("a-first", `#!/bin/sh | ||
| 426 | - | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}' | 455 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"a-first","version":"0.1","capabilities":["commands","tools"]}' |
| 427 | printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}' | 456 | printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}' | ||
| 428 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | 457 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | ||
| 429 | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | 458 | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | ||
| @@ -435,7 +464,7 @@ while IFS= read -r line; do | |||||
| 435 | done | 464 | done | ||
| 436 | `) | 465 | `) | ||
| 437 | writeDiagExtension("b-second", `#!/bin/sh | 466 | writeDiagExtension("b-second", `#!/bin/sh | ||
| 438 | - | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}' | 467 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"b-second","version":"0.1","capabilities":["commands","tools"]}' |
| 439 | printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}' | 468 | printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}' | ||
| 440 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | 469 | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | ||
| 441 | printf '%s\n' '{"type":"ready"}' | 470 | printf '%s\n' '{"type":"ready"}' | ||
| @@ -512,7 +541,7 @@ func TestSpontaneousSubmit(t *testing.T) { | |||||
| 512 | } | 541 | } | ||
| 513 | 542 | ||||
| 514 | script := `#!/bin/sh | 543 | script := `#!/bin/sh | ||
| 515 | - | printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}' | 544 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"submit-mock","version":"0.1","capabilities":["submit"]}' |
| 516 | printf '%s\n' '{"type":"ready"}' | 545 | printf '%s\n' '{"type":"ready"}' | ||
| 517 | printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}' | 546 | printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}' | ||
| 518 | printf '%s\n' '{"type":"submit","text":" "}' | 547 | printf '%s\n' '{"type":"submit","text":" "}' | ||
| @@ -575,7 +604,7 @@ func TestSpontaneousAlert(t *testing.T) { | |||||
| 575 | } | 604 | } | ||
| 576 | 605 | ||||
| 577 | script := `#!/bin/sh | 606 | script := `#!/bin/sh | ||
| 578 | - | printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}' | 607 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"alert-mock","version":"0.1","capabilities":["alerts"]}' |
| 579 | printf '%s\n' '{"type":"ready"}' | 608 | printf '%s\n' '{"type":"ready"}' | ||
| 580 | printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}' | 609 | printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}' | ||
| 581 | while IFS= read -r line; do | 610 | while IFS= read -r line; do | ||
| @@ -643,7 +672,7 @@ func TestSpontaneousOpenPanel(t *testing.T) { | |||||
| 643 | // Extension emits hello + ready, then immediately fires a | 672 | // Extension emits hello + ready, then immediately fires a | ||
| 644 | // spontaneous open_panel, then waits for shutdown. | 673 | // spontaneous open_panel, then waits for shutdown. | ||
| 645 | script := `#!/bin/sh | 674 | script := `#!/bin/sh | ||
| 646 | - | printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}' | 675 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"panel-mock","version":"0.1","capabilities":["panels"]}' |
| 647 | printf '%s\n' '{"type":"ready"}' | 676 | printf '%s\n' '{"type":"ready"}' | ||
| 648 | printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}' | 677 | printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}' | ||
| 649 | while IFS= read -r line; do | 678 | while IFS= read -r line; do | ||
M packages/agent/extensions/tool_test.go +1 -1
| @@ -23,7 +23,7 @@ func writeMockToolExtension(t *testing.T, root string) { | |||
| 23 | 23 | // Shell-script extension: hello, register one tool, ready, then | |
| 24 | 24 | // loop on stdin echoing back tool_call args as tool_result text. | |
| 25 | 25 | script := `#!/bin/sh | |
| 26 | - | printf '%s\n' '{"type":"hello","name":"tool-mock","version":"0.1","capabilities":["tools"]}' | |
| 26 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"tool-mock","version":"0.1","capabilities":["tools"]}' | |
| 27 | 27 | printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}' | |
| 28 | 28 | printf '%s\n' '{"type":"ready"}' | |
| 29 | 29 | while IFS= read -r line; do | |
| @@ -23,7 +23,7 @@ func writeMockToolExtension(t *testing.T, root string) { | |||||
| 23 | // Shell-script extension: hello, register one tool, ready, then | 23 | // Shell-script extension: hello, register one tool, ready, then | ||
| 24 | // loop on stdin echoing back tool_call args as tool_result text. | 24 | // loop on stdin echoing back tool_call args as tool_result text. | ||
| 25 | script := `#!/bin/sh | 25 | script := `#!/bin/sh | ||
| 26 | - | printf '%s\n' '{"type":"hello","name":"tool-mock","version":"0.1","capabilities":["tools"]}' | 26 | + | printf '%s\n' '{"type":"hello","protocol_version":2,"name":"tool-mock","version":"0.1","capabilities":["tools"]}' |
| 27 | printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}' | 27 | printf '%s\n' '{"type":"register_tool","name":"echo","description":"echo back the args","schema":{"type":"object","properties":{"msg":{"type":"string"}}}}' | ||
| 28 | printf '%s\n' '{"type":"ready"}' | 28 | printf '%s\n' '{"type":"ready"}' | ||
| 29 | while IFS= read -r line; do | 29 | while IFS= read -r line; do | ||
M packages/agent/extproto/extproto.go +8 -5
| @@ -22,7 +22,9 @@ import ( | |||
| 22 | 22 | "strings" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | - | const ProtocolVersion = 1 | |
| 25 | + | // ProtocolVersion is the extension protocol major. Version 2 introduces the | |
| 26 | + | // zut_version wire key and requires both sides to reject mismatched majors. | |
| 27 | + | const ProtocolVersion = 2 | |
| 26 | 28 | ||
| 27 | 29 | type Frame struct { | |
| 28 | 30 | Type string `json:"type"` | |
| @@ -30,10 +32,11 @@ type Frame struct { | |||
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | type HelloFromExt struct { | |
| 33 | - | Type string `json:"type"` | |
| 34 | - | Name string `json:"name"` | |
| 35 | - | Version string `json:"version"` | |
| 36 | - | Capabilities []string `json:"capabilities,omitempty"` | |
| 35 | + | Type string `json:"type"` | |
| 36 | + | ProtocolVersion int `json:"protocol_version"` | |
| 37 | + | Name string `json:"name"` | |
| 38 | + | Version string `json:"version"` | |
| 39 | + | Capabilities []string `json:"capabilities,omitempty"` | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | type RegisterCommandFromExt struct { | |
| @@ -22,7 +22,9 @@ import ( | |||||
| 22 | "strings" | 22 | "strings" | ||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | - | const ProtocolVersion = 1 | 25 | + | // ProtocolVersion is the extension protocol major. Version 2 introduces the |
| 26 | + | // zut_version wire key and requires both sides to reject mismatched majors. | |||
| 27 | + | const ProtocolVersion = 2 | |||
| 26 | 28 | ||||
| 27 | type Frame struct { | 29 | type Frame struct { | ||
| 28 | Type string `json:"type"` | 30 | Type string `json:"type"` | ||
| @@ -30,10 +32,11 @@ type Frame struct { | |||||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | type HelloFromExt struct { | 34 | type HelloFromExt struct { | ||
| 33 | - | Type string `json:"type"` | 35 | + | Type string `json:"type"` |
| 34 | - | Name string `json:"name"` | 36 | + | ProtocolVersion int `json:"protocol_version"` |
| 35 | - | Version string `json:"version"` | 37 | + | Name string `json:"name"` |
| 36 | - | Capabilities []string `json:"capabilities,omitempty"` | 38 | + | Version string `json:"version"` |
| 39 | + | Capabilities []string `json:"capabilities,omitempty"` | |||
| 37 | } | 40 | } | ||
| 38 | 41 | ||||
| 39 | type RegisterCommandFromExt struct { | 42 | type RegisterCommandFromExt struct { | ||
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +10 -6
| @@ -92,17 +92,18 @@ their responses. | |||
| 92 | 92 | The very first frame the extension sends is `hello`: | |
| 93 | 93 | ||
| 94 | 94 | ```json | |
| 95 | - | {"type":"hello","name":"weather","version":"1.0.0", | |
| 95 | + | {"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0", | |
| 96 | 96 | "capabilities":["commands","tools"]} | |
| 97 | 97 | ``` | |
| 98 | 98 | ||
| 99 | 99 | Capabilities are advisory; current values are `commands`, `tools`, | |
| 100 | - | `events`, `alerts`, and `panels`. Send all that apply. | |
| 100 | + | `events`, `alerts`, and `panels`. Send all that apply. `protocol_version` | |
| 101 | + | is the major wire version; it is currently `2` and must match the host. | |
| 101 | 102 | ||
| 102 | 103 | zut replies with `hello_ack`: | |
| 103 | 104 | ||
| 104 | 105 | ```json | |
| 105 | - | {"type":"hello_ack","protocol_version":1,"zut_version":"0.0.x", | |
| 106 | + | {"type":"hello_ack","protocol_version":2,"zut_version":"0.0.x", | |
| 106 | 107 | "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"} | |
| 107 | 108 | ``` | |
| 108 | 109 | ||
| @@ -275,13 +276,14 @@ import { stderr, stdin, stdout } from "node:process"; | |||
| 275 | 276 | function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); } | |
| 276 | 277 | function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); } | |
| 277 | 278 | ||
| 278 | - | send({ type: "hello", name: "scratchpad", version: "1.0.0", | |
| 279 | + | send({ type: "hello", protocol_version: 2, name: "scratchpad", version: "1.0.0", | |
| 279 | 280 | capabilities: ["commands", "tools"] }); | |
| 280 | 281 | ||
| 281 | 282 | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | |
| 282 | 283 | rl.on("line", (line) => { | |
| 283 | 284 | const f = JSON.parse(line); | |
| 284 | 285 | if (f.type === "hello_ack") { | |
| 286 | + | if (f.protocol_version !== 2) throw new Error("unsupported extension protocol version"); | |
| 285 | 287 | // f.cwd is the user's project directory. | |
| 286 | 288 | send({ type: "register_command", name: "note", description: "append a note" }); | |
| 287 | 289 | send({ type: "register_tool", name: "read_notes", | |
| @@ -317,11 +319,13 @@ import json, sys | |||
| 317 | 319 | ||
| 318 | 320 | def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush() | |
| 319 | 321 | ||
| 320 | - | emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]}) | |
| 322 | + | emit({"type": "hello", "protocol_version": 2, "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]}) | |
| 321 | 323 | ||
| 322 | 324 | for line in sys.stdin: | |
| 323 | 325 | msg = json.loads(line) | |
| 324 | 326 | if msg["type"] == "hello_ack": | |
| 327 | + | if msg.get("protocol_version") != 2: | |
| 328 | + | raise SystemExit("unsupported extension protocol version") | |
| 325 | 329 | # msg["cwd"] is the user's project directory. | |
| 326 | 330 | emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"}) | |
| 327 | 331 | emit({"type": "ready"}) | |
| @@ -367,7 +371,7 @@ to see exactly what's happening on the wire: | |||
| 367 | 371 | ||
| 368 | 372 | ```bash | |
| 369 | 373 | { | |
| 370 | - | printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}' | |
| 374 | + | printf '%s\n' '{"type":"hello_ack","protocol_version":2,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}' | |
| 371 | 375 | sleep 0.2 | |
| 372 | 376 | printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}' | |
| 373 | 377 | sleep 0.5 | |
| @@ -92,17 +92,18 @@ their responses. | |||||
| 92 | The very first frame the extension sends is `hello`: | 92 | The very first frame the extension sends is `hello`: | ||
| 93 | 93 | ||||
| 94 | ```json | 94 | ```json | ||
| 95 | - | {"type":"hello","name":"weather","version":"1.0.0", | 95 | + | {"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0", |
| 96 | "capabilities":["commands","tools"]} | 96 | "capabilities":["commands","tools"]} | ||
| 97 | ``` | 97 | ``` | ||
| 98 | 98 | ||||
| 99 | Capabilities are advisory; current values are `commands`, `tools`, | 99 | Capabilities are advisory; current values are `commands`, `tools`, | ||
| 100 | - | `events`, `alerts`, and `panels`. Send all that apply. | 100 | + | `events`, `alerts`, and `panels`. Send all that apply. `protocol_version` |
| 101 | + | is the major wire version; it is currently `2` and must match the host. | |||
| 101 | 102 | ||||
| 102 | zut replies with `hello_ack`: | 103 | zut replies with `hello_ack`: | ||
| 103 | 104 | ||||
| 104 | ```json | 105 | ```json | ||
| 105 | - | {"type":"hello_ack","protocol_version":1,"zut_version":"0.0.x", | 106 | + | {"type":"hello_ack","protocol_version":2,"zut_version":"0.0.x", |
| 106 | "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"} | 107 | "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"} | ||
| 107 | ``` | 108 | ``` | ||
| 108 | 109 | ||||
| @@ -275,13 +276,14 @@ import { stderr, stdin, stdout } from "node:process"; | |||||
| 275 | function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); } | 276 | function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); } | ||
| 276 | function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); } | 277 | function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); } | ||
| 277 | 278 | ||||
| 278 | - | send({ type: "hello", name: "scratchpad", version: "1.0.0", | 279 | + | send({ type: "hello", protocol_version: 2, name: "scratchpad", version: "1.0.0", |
| 279 | capabilities: ["commands", "tools"] }); | 280 | capabilities: ["commands", "tools"] }); | ||
| 280 | 281 | ||||
| 281 | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | 282 | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | ||
| 282 | rl.on("line", (line) => { | 283 | rl.on("line", (line) => { | ||
| 283 | const f = JSON.parse(line); | 284 | const f = JSON.parse(line); | ||
| 284 | if (f.type === "hello_ack") { | 285 | if (f.type === "hello_ack") { | ||
| 286 | + | if (f.protocol_version !== 2) throw new Error("unsupported extension protocol version"); | |||
| 285 | // f.cwd is the user's project directory. | 287 | // f.cwd is the user's project directory. | ||
| 286 | send({ type: "register_command", name: "note", description: "append a note" }); | 288 | send({ type: "register_command", name: "note", description: "append a note" }); | ||
| 287 | send({ type: "register_tool", name: "read_notes", | 289 | send({ type: "register_tool", name: "read_notes", | ||
| @@ -317,11 +319,13 @@ import json, sys | |||||
| 317 | 319 | ||||
| 318 | def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush() | 320 | def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush() | ||
| 319 | 321 | ||||
| 320 | - | emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]}) | 322 | + | emit({"type": "hello", "protocol_version": 2, "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]}) |
| 321 | 323 | ||||
| 322 | for line in sys.stdin: | 324 | for line in sys.stdin: | ||
| 323 | msg = json.loads(line) | 325 | msg = json.loads(line) | ||
| 324 | if msg["type"] == "hello_ack": | 326 | if msg["type"] == "hello_ack": | ||
| 327 | + | if msg.get("protocol_version") != 2: | |||
| 328 | + | raise SystemExit("unsupported extension protocol version") | |||
| 325 | # msg["cwd"] is the user's project directory. | 329 | # msg["cwd"] is the user's project directory. | ||
| 326 | emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"}) | 330 | emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"}) | ||
| 327 | emit({"type": "ready"}) | 331 | emit({"type": "ready"}) | ||
| @@ -367,7 +371,7 @@ to see exactly what's happening on the wire: | |||||
| 367 | 371 | ||||
| 368 | ```bash | 372 | ```bash | ||
| 369 | { | 373 | { | ||
| 370 | - | printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}' | 374 | + | printf '%s\n' '{"type":"hello_ack","protocol_version":2,"zut_version":"x","provider":"a","model":"o","cwd":"/tmp"}' |
| 371 | sleep 0.2 | 375 | sleep 0.2 | ||
| 372 | printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}' | 376 | printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}' | ||
| 373 | sleep 0.5 | 377 | sleep 0.5 | ||
M packages/provider/auth/auth_test.go +1 -1
| @@ -118,7 +118,7 @@ func TestOpenAIAuthorizeURL(t *testing.T) { | |||
| 118 | 118 | if state == p.Verifier { | |
| 119 | 119 | t.Fatal("openai state should be random, not verifier") | |
| 120 | 120 | } | |
| 121 | - | if q.Get("originator") != "zut" { | |
| 121 | + | if q.Get("originator") != "zot" { | |
| 122 | 122 | t.Fatalf("originator=%q", q.Get("originator")) | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| @@ -118,7 +118,7 @@ func TestOpenAIAuthorizeURL(t *testing.T) { | |||||
| 118 | if state == p.Verifier { | 118 | if state == p.Verifier { | ||
| 119 | t.Fatal("openai state should be random, not verifier") | 119 | t.Fatal("openai state should be random, not verifier") | ||
| 120 | } | 120 | } | ||
| 121 | - | if q.Get("originator") != "zut" { | 121 | + | if q.Get("originator") != "zot" { |
| 122 | t.Fatalf("originator=%q", q.Get("originator")) | 122 | t.Fatalf("originator=%q", q.Get("originator")) | ||
| 123 | } | 123 | } | ||
| 124 | } | 124 | } | ||
M packages/provider/auth/oauth.go +3 -1
| @@ -108,7 +108,9 @@ var ( | |||
| 108 | 108 | ExtraAuthArgs: map[string]string{ | |
| 109 | 109 | "id_token_add_organizations": "true", | |
| 110 | 110 | "codex_cli_simplified_flow": "true", | |
| 111 | - | "originator": "zut", | |
| 111 | + | // OpenAI's registered Codex OAuth flow requires this legacy | |
| 112 | + | // originator value; it is not a zut product identifier. | |
| 113 | + | "originator": "zot", | |
| 112 | 114 | }, | |
| 113 | 115 | } | |
| 114 | 116 | ||
| @@ -108,7 +108,9 @@ var ( | |||||
| 108 | ExtraAuthArgs: map[string]string{ | 108 | ExtraAuthArgs: map[string]string{ | ||
| 109 | "id_token_add_organizations": "true", | 109 | "id_token_add_organizations": "true", | ||
| 110 | "codex_cli_simplified_flow": "true", | 110 | "codex_cli_simplified_flow": "true", | ||
| 111 | - | "originator": "zut", | 111 | + | // OpenAI's registered Codex OAuth flow requires this legacy |
| 112 | + | // originator value; it is not a zut product identifier. | |||
| 113 | + | "originator": "zot", | |||
| 112 | }, | 114 | }, | ||
| 113 | } | 115 | } | ||
| 114 | 116 | ||||
M packages/provider/openai_codex.go +5 -3
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | // Wire protocol notes: | |
| 22 | 22 | // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses | |
| 23 | 23 | // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>, | |
| 24 | - | // OpenAI-Beta: responses=experimental, originator: zut | |
| 24 | + | // OpenAI-Beta: responses=experimental, originator: zot (required legacy value) | |
| 25 | 25 | // - Body: OpenAI Responses API shape (not chat/completions). | |
| 26 | 26 | // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}] | |
| 27 | 27 | // instructions: <system prompt> | |
| @@ -410,8 +410,10 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 410 | 410 | httpReq.Header.Set("session-id", codexCLISessionID) | |
| 411 | 411 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | |
| 412 | 412 | } else { | |
| 413 | - | httpReq.Header.Set("originator", "zut") | |
| 414 | - | httpReq.Header.Set("user-agent", fmt.Sprintf("zut (%s %s)", runtime.GOOS, runtime.GOARCH)) | |
| 413 | + | // ChatGPT's backend recognizes these established values. They are | |
| 414 | + | // external provider metadata, not zut product identifiers. | |
| 415 | + | httpReq.Header.Set("originator", "zot") | |
| 416 | + | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |
| 415 | 417 | } | |
| 416 | 418 | return httpReq, nil | |
| 417 | 419 | } | |
| @@ -21,7 +21,7 @@ import ( | |||||
| 21 | // Wire protocol notes: | 21 | // Wire protocol notes: | ||
| 22 | // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses | 22 | // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses | ||
| 23 | // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>, | 23 | // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>, | ||
| 24 | - | // OpenAI-Beta: responses=experimental, originator: zut | 24 | + | // OpenAI-Beta: responses=experimental, originator: zot (required legacy value) |
| 25 | // - Body: OpenAI Responses API shape (not chat/completions). | 25 | // - Body: OpenAI Responses API shape (not chat/completions). | ||
| 26 | // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}] | 26 | // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}] | ||
| 27 | // instructions: <system prompt> | 27 | // instructions: <system prompt> | ||
| @@ -410,8 +410,10 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 410 | httpReq.Header.Set("session-id", codexCLISessionID) | 410 | httpReq.Header.Set("session-id", codexCLISessionID) | ||
| 411 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | 411 | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | ||
| 412 | } else { | 412 | } else { | ||
| 413 | - | httpReq.Header.Set("originator", "zut") | 413 | + | // ChatGPT's backend recognizes these established values. They are |
| 414 | - | httpReq.Header.Set("user-agent", fmt.Sprintf("zut (%s %s)", runtime.GOOS, runtime.GOARCH)) | 414 | + | // external provider metadata, not zut product identifiers. |
| 415 | + | httpReq.Header.Set("originator", "zot") | |||
| 416 | + | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |||
| 415 | } | 417 | } | ||
| 416 | return httpReq, nil | 418 | return httpReq, nil | ||
| 417 | } | 419 | } | ||
M packages/provider/openai_codex_test.go +7 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "io" | |
| 8 | 8 | "net/http" | |
| 9 | + | "runtime" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 | ) | |
| @@ -175,6 +176,12 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 175 | 176 | if gotBody.PromptCacheKey != "" { | |
| 176 | 177 | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | |
| 177 | 178 | } | |
| 179 | + | if gotReq.Header.Get("originator") != "zot" { | |
| 180 | + | t.Fatalf("originator = %q", gotReq.Header.Get("originator")) | |
| 181 | + | } | |
| 182 | + | if gotReq.Header.Get("user-agent") != "zot ("+runtime.GOOS+" "+runtime.GOARCH+")" { | |
| 183 | + | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |
| 184 | + | } | |
| 178 | 185 | } | |
| 179 | 186 | ||
| 180 | 187 | func TestCodexNestedStreamError(t *testing.T) { | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | + | "runtime" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "testing" | 11 | "testing" | ||
| 11 | ) | 12 | ) | ||
| @@ -175,6 +176,12 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||||
| 175 | if gotBody.PromptCacheKey != "" { | 176 | if gotBody.PromptCacheKey != "" { | ||
| 176 | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | 177 | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | ||
| 177 | } | 178 | } | ||
| 179 | + | if gotReq.Header.Get("originator") != "zot" { | |||
| 180 | + | t.Fatalf("originator = %q", gotReq.Header.Get("originator")) | |||
| 181 | + | } | |||
| 182 | + | if gotReq.Header.Get("user-agent") != "zot ("+runtime.GOOS+" "+runtime.GOARCH+")" { | |||
| 183 | + | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |||
| 184 | + | } | |||
| 178 | } | 185 | } | ||
| 179 | 186 | ||||
| 180 | func TestCodexNestedStreamError(t *testing.T) { | 187 | func TestCodexNestedStreamError(t *testing.T) { | ||