mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Compare commits
103
Commits
No files matched your search
@@ -14,7 +14,8 @@ name: Main landing gate
|
||||
# This job is deliberately narrow. It runs `scripts/preflight.sh --check`,
|
||||
# which is the checked-in local preflight for exactly the class of breakage that
|
||||
# reaches `main` unnoticed: rustfmt, the CHANGELOG slice, README locale stamps,
|
||||
# the dead-code and blocking-calls budgets, generated-asset drift, and the
|
||||
# the dead-code and blocking-calls budgets, generated-asset drift (including
|
||||
# the website's generated facts, agent docs and feature registry), and the
|
||||
# feature receipts. Measured at 45 s on a warm Linux runner.
|
||||
#
|
||||
# Since 2026-11-08 (founder) the `full-ci` job also dispatches `ci.yml` on every
|
||||
@@ -50,6 +51,10 @@ jobs:
|
||||
toolchain: stable
|
||||
components: rustfmt
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Preflight
|
||||
env:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
name: Web maintainer
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '43 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
lane:
|
||||
description: Which lane to run
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- derived
|
||||
- copy-freshness
|
||||
- link-health
|
||||
- docs-drift
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: web-maintainer
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
WEB_MAINTAINER_CLI_VERSION: v0.10.2
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan
|
||||
if: github.repository == 'codewhale-hq/Codewhale' && github.ref == 'refs/heads/main' && vars.WEB_MAINTAINER_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
outputs:
|
||||
lanes: ${{ steps.plan.outputs.lanes }}
|
||||
derived: ${{ steps.plan.outputs.derived }}
|
||||
steps:
|
||||
- id: plan
|
||||
env:
|
||||
INPUT_LANE: ${{ inputs.lane || 'all' }}
|
||||
run: |
|
||||
derived=false
|
||||
lanes='[]'
|
||||
case "$INPUT_LANE" in
|
||||
all) derived=true; lanes='["copy-freshness","link-health","docs-drift"]' ;;
|
||||
derived) derived=true ;;
|
||||
copy-freshness|link-health|docs-drift) lanes="[\"${INPUT_LANE}\"]" ;;
|
||||
*) echo "::error::unknown lane"; exit 1 ;;
|
||||
esac
|
||||
echo "derived=${derived}" >> "$GITHUB_OUTPUT"
|
||||
echo "lanes=${lanes}" >> "$GITHUB_OUTPUT"
|
||||
echo "Web maintainer plan: derived=${derived} lanes=${lanes}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
derived:
|
||||
name: Derived files
|
||||
needs: plan
|
||||
if: needs.plan.result == 'success' && needs.plan.outputs.derived == 'true' && vars.WEB_MAINTAINER_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install web dependencies
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Regenerate derived files
|
||||
working-directory: web
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/sync-latest-release.mjs
|
||||
node scripts/derive-facts.mjs
|
||||
node scripts/derive-changelog.mjs
|
||||
node scripts/derive-install.mjs
|
||||
node scripts/render-terminal-capture.mjs
|
||||
- name: Check what changed
|
||||
id: drift
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/out"
|
||||
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "::error::regeneration produced untracked files"
|
||||
git ls-files --others --exclude-standard
|
||||
exit 1
|
||||
fi
|
||||
git diff --name-status | node web/maintainer/check-allowlist.mjs web/maintainer/derived.txt derived
|
||||
if [ -n "$(git diff --summary)" ]; then
|
||||
echo "::error::regeneration created, deleted or changed modes"
|
||||
exit 1
|
||||
fi
|
||||
git diff --binary > "$RUNNER_TEMP/out/change.patch"
|
||||
if [ -s "$RUNNER_TEMP/out/change.patch" ]; then
|
||||
echo "drift=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "drift=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Derived files are current." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Run the web checks on the regenerated tree
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
npm run check
|
||||
npx vitest run
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
with:
|
||||
name: web-maintainer-derived
|
||||
path: ${{ runner.temp }}/out/change.patch
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
lane:
|
||||
name: Lane ${{ matrix.lane }}
|
||||
needs: plan
|
||||
if: needs.plan.result == 'success' && needs.plan.outputs.lanes != '[]' && vars.WEB_MAINTAINER_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
lane: ${{ fromJSON(needs.plan.outputs.lanes) }}
|
||||
env:
|
||||
LANE: ${{ matrix.lane }}
|
||||
steps:
|
||||
- name: Require the DeepSeek key
|
||||
env:
|
||||
KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$KEY" ]; then
|
||||
echo "::error::WEB_MAINTAINER_DEEPSEEK_API_KEY is not set. Create a dedicated DeepSeek key with a provider-side monthly cap and add it as that repository secret, or unset WEB_MAINTAINER_ENABLED. See web/maintainer/README.md."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Prove the command sandbox works or stop
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends bubblewrap util-linux >/dev/null
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null || true
|
||||
probe="$GITHUB_WORKSPACE/.sandbox-probe"
|
||||
rm -f "$probe" /usr/local/sandbox-probe
|
||||
if ! timeout 10 bash -c 'exec 3<>/dev/tcp/api.deepseek.com/443'; then
|
||||
echo "::error::the runner cannot reach api.deepseek.com, so the network denial cannot be proven."
|
||||
exit 1
|
||||
fi
|
||||
if ! /usr/bin/bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
|
||||
--bind "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE" --chdir "$GITHUB_WORKSPACE" -- \
|
||||
bash -c 'touch .sandbox-probe && ! touch /usr/local/sandbox-probe 2>/dev/null && ! timeout 10 bash -c "exec 3<>/dev/tcp/api.deepseek.com/443" 2>/dev/null'; then
|
||||
echo "::error::bubblewrap did not confine a child on this runner (writes outside the workspace or outbound network were not denied). Refusing to give a model a shell."
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -e "$probe" ] || [ -e /usr/local/sandbox-probe ]; then
|
||||
echo "::error::sandbox probe left unexpected state. Refusing to give a model a shell."
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$probe"
|
||||
echo "Sandbox probe passed: workspace-only writes, no outbound network." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Stage trusted tooling and the pinned CLI
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/trusted" "$RUNNER_TEMP/home"
|
||||
cp web/maintainer/allowlist.txt web/maintainer/check-allowlist.mjs "$RUNNER_TEMP/trusted/"
|
||||
cp "web/maintainer/lanes/${LANE}.md" "$RUNNER_TEMP/trusted/prompt.md"
|
||||
CODEWHALE_VERSION="$WEB_MAINTAINER_CLI_VERSION" CODEWHALE_INSTALL_DIR="$RUNNER_TEMP/bin" sh web/public/install.sh
|
||||
"$RUNNER_TEMP/bin/codewhale" --version
|
||||
- name: Probe outbound links
|
||||
if: matrix.lane == 'link-health'
|
||||
run: node web/maintainer/link-report.mjs "$GITHUB_WORKSPACE/.git/maintainer-input/link-report.json"
|
||||
- name: Record the git state the model must not change
|
||||
run: |
|
||||
git config --local --list | sort > "$RUNNER_TEMP/git-config.before"
|
||||
find .git/hooks -type f ! -name '*.sample' > "$RUNNER_TEMP/hooks.before"
|
||||
test ! -s "$RUNNER_TEMP/hooks.before"
|
||||
- name: Run the lane on DeepSeek
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
WEB_MAINTAINER_DEEPSEEK_API_KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
|
||||
run: |
|
||||
env -i \
|
||||
PATH="$PATH" \
|
||||
HOME="$RUNNER_TEMP/home" \
|
||||
CODEWHALE_HOME="$RUNNER_TEMP/home/.codewhale" \
|
||||
CODEWHALE_SECRET_BACKEND=file \
|
||||
CODEWHALE_MEMORY=false \
|
||||
CODEWHALE_TELEMETRY=false \
|
||||
DEEPSEEK_API_KEY="$WEB_MAINTAINER_DEEPSEEK_API_KEY" \
|
||||
"$RUNNER_TEMP/bin/codewhale" \
|
||||
--no-project-config \
|
||||
--skip-onboarding \
|
||||
--workspace "$GITHUB_WORKSPACE" \
|
||||
exec \
|
||||
--provider deepseek \
|
||||
--model deepseek-flash \
|
||||
--reasoning-effort low \
|
||||
--auto \
|
||||
--sandbox workspace-write \
|
||||
--max-turns 40 \
|
||||
--output-format stream-json \
|
||||
-- "$(cat "$RUNNER_TEMP/trusted/prompt.md")" \
|
||||
> "$RUNNER_TEMP/exec.jsonl"
|
||||
echo "Model run finished: $(wc -l < "$RUNNER_TEMP/exec.jsonl") stream events." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Enforce the path allowlist
|
||||
id: scope
|
||||
run: |
|
||||
if git status --porcelain --untracked-files=all --ignored | grep -qv '^ M '; then
|
||||
echo "::error::the lane left untracked, ignored, staged or non-edit entries"
|
||||
git status --porcelain --untracked-files=all --ignored | grep -v '^ M '
|
||||
exit 1
|
||||
fi
|
||||
git config --local --list | sort | diff - "$RUNNER_TEMP/git-config.before" || { echo "::error::the lane changed .git/config"; exit 1; }
|
||||
find .git/hooks -type f ! -name '*.sample' | diff - "$RUNNER_TEMP/hooks.before" || { echo "::error::the lane added git hooks"; exit 1; }
|
||||
git diff --name-status | node "$RUNNER_TEMP/trusted/check-allowlist.mjs" "$RUNNER_TEMP/trusted/allowlist.txt" "$LANE"
|
||||
if [ -n "$(git diff --summary)" ]; then
|
||||
echo "::error::the lane created, deleted or changed modes"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$RUNNER_TEMP/out"
|
||||
git diff --binary > "$RUNNER_TEMP/out/change.patch"
|
||||
if [ ! -s "$RUNNER_TEMP/out/change.patch" ]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Lane ${LANE}: no change proposed." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$(wc -c < "$RUNNER_TEMP/out/change.patch")" -gt 262144 ]; then
|
||||
echo "::error::patch exceeds 256 KiB"
|
||||
exit 1
|
||||
fi
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
{ echo "Lane ${LANE} proposes:"; echo '```text'; git diff --stat; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Fresh checkout for verification
|
||||
if: steps.scope.outputs.changed == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: main
|
||||
path: verify
|
||||
persist-credentials: false
|
||||
- name: Run the web checks on the proposal in a clean tree without secrets
|
||||
if: steps.scope.outputs.changed == 'true'
|
||||
env:
|
||||
PATCH: ${{ runner.temp }}/out/change.patch
|
||||
run: |
|
||||
cd verify
|
||||
git apply --index --whitespace=nowarn "$PATCH"
|
||||
cd web
|
||||
npm ci --no-audit --no-fund
|
||||
npm run check
|
||||
npx vitest run
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: steps.scope.outputs.changed == 'true'
|
||||
with:
|
||||
name: web-maintainer-${{ matrix.lane }}
|
||||
path: ${{ runner.temp }}/out/change.patch
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Open pull requests
|
||||
needs: [plan, derived, lane]
|
||||
if: always() && needs.plan.result == 'success' && vars.WEB_MAINTAINER_ENABLED == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: web-maintainer-*
|
||||
path: ${{ runner.temp }}/patches
|
||||
- name: Look for proposed patches
|
||||
id: patches
|
||||
run: |
|
||||
if [ -n "$(find "$RUNNER_TEMP/patches" -name change.patch -size +0c 2>/dev/null)" ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No lane proposed a change; no pull request opened." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- if: steps.patches.outputs.found == 'true'
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ vars.CODEWHALE_APP_ID }}
|
||||
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
- if: steps.patches.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
- name: Open or update one pull request per lane
|
||||
if: steps.patches.outputs.found == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
|
||||
ARTIFACTS_DIR: ${{ runner.temp }}/patches
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/trusted"
|
||||
cp web/maintainer/allowlist.txt web/maintainer/derived.txt web/maintainer/check-allowlist.mjs web/maintainer/publish-prs.sh "$RUNNER_TEMP/trusted/"
|
||||
TRUSTED_DIR="$RUNNER_TEMP/trusted" bash "$RUNNER_TEMP/trusted/publish-prs.sh"
|
||||
+49
-6
@@ -10,25 +10,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
|
||||
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
|
||||
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
|
||||
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
|
||||
- **Anthropic xhigh effort:** Choosing xhigh effort on an Anthropic model that documents it, such as Claude Fable 5.2 or Claude Opus 4.8, now sends xhigh instead of max, so the model uses and bills the level you picked.
|
||||
- **Public model list:** The public model list gains GPT-6.1 Terra, Kimi K3.1 Pro (and its high-speed variant), MiniMax M3.1 Flash and Mistral Large 4, each marked unreviewed.
|
||||
- **Model Studio Token Plan:** Token Plan users can now pick `qwen4-max-preview` on both the OpenAI and Anthropic dialects. It was added from the model table Alibaba publishes on its Token Plan pricing page and has not been exercised against the live endpoint. Alibaba publishes no context window, output limit, tool calling or thinking control for it, so Codewhale records image input only and sends it no thinking switch on either dialect. The Coding Plan does not list it.
|
||||
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
|
||||
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
|
||||
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
|
||||
- **Website docs:** documentation pages no longer stack a second "Codewhale documentation" header under the site navigation; the line saying which release the docs describe now sits under the breadcrumb.
|
||||
- **Website languages:** the Roadmap and Contribute pages now show their titles, introductions and actions in all 18 site languages instead of English outside English and Chinese. The translations have had no native review yet.
|
||||
- **Responses dialect profiles:** Codex, DeepSeek and Concentrate Responses behavior now comes from one checked dialect table instead of per-provider code, a custom `wire = "responses"` provider can set `responses_profile = "standard"` (the default when omitted), and preview shows the profile each Responses request used. A Responses client whose route cannot be resolved now fails when it is created rather than at its first request ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)). Thanks [@SamhandsomeLee](https://github.com/SamhandsomeLee).
|
||||
- **Read-only builtins over the Runtime API:** `POST /v1/threads/{id}/commands/builtin` runs `/system`, `/tokens` or `/cost` against a thread and returns the same answer the terminal prints, including the effective system prompt the engine sends. Any other command name answers 404, a thread whose engine is not loaded or has no live context estimate answers 409, and the route never starts an engine or writes settings ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)). Verified by the app-server route tests and terminal-host tests; no editor client uses it yet. Thanks [@gaord](https://github.com/gaord).
|
||||
- **Automations:** a run missed while Codewhale was closed now waits on reopen for Run now or Skip instead of catching up silently; each automation can choose "If missed: ask" (default), "run once" or "skip". Tested in the engine only; no HTTP route tests, no desktop client yet, and a Mac asleep longer than the grace window counts as closed.
|
||||
- **Website maintainer:** a scheduled `web-maintainer` workflow proposes website upkeep as pull requests only: regenerated facts, and three DeepSeek lanes (stale version mentions, moved links, docs drift) that may edit only allowlisted copy files and must pass `npm run check` and the web tests. It is off until the repository variable `WEB_MAINTAINER_ENABLED` is set and a capped `WEB_MAINTAINER_DEEPSEEK_API_KEY` secret exists, and it has never run. The Worker's triage, PR-review, stale, duplicate and curate (Today's Dispatch drafting) cron tasks are removed; the digest stays.
|
||||
- **Website measurement:** the first-party ingest now accepts a closed website funnel schema while preserving existing runtime and browser batches. Reader confirmations remain self-reports, new website data stays first-party, and no deployment or live conversion gain is established by the source change.
|
||||
- **Website facts:** the website now reads the installed version, asset list and repository numbers from the published release and a dated snapshot instead of the source tree, shows nothing rather than 0 when a live number cannot be read, stamps its facts revision and date together, no longer calls the desktop app native, says when the release was read, and drops the price from its structured data. This is a source change and is not deployed yet.
|
||||
- **Website surface status:** the status of the terminal, browser, desktop, cloud and accounts surfaces now comes from typed states with copy in every locale, the desktop download appears only for a signed, notarized release with a checksum, the cloud-invitation and plan switches are committed values that default to off, the partner strip expires at render time, and repository numbers show when they were read. This is a source change and is not deployed yet.
|
||||
- **Website capabilities:** the website now lists a capability as available only when the published release has it, labels features that exist only in source as not yet released, keeps held features (cloud computers beyond invite-only, other vendors' agents over ACP, and plans) hidden behind committed switches that default to off, and prints provider route, catalog model and tool counts as they are at the published release tag instead of on main. This is not deployed yet.
|
||||
- **Installer uninstall lines:** the installers now print the uninstall command, the update-mode sentence and the shell-profile marker sentence only when the Codewhale they just installed reports an `uninstall` command, and older releases such as a pinned v0.10.2 get the manual removal steps instead of a command that would be sent to the model as a prompt. (CW-8029) The installers reach people through the website, so this is not deployed yet.
|
||||
- **Installer PATH ending:** when `codewhale` is not on PATH, the installers' last lines are now the copy-paste `export PATH=...; hash -r` command and `codewhale --version`, and when it is on PATH the last line is `Next: codewhale --version`; the Computer Use notice moves above them and shows on macOS only. (CW-8030) The installers reach people through the website, so this is not deployed yet.
|
||||
- **Installer PATH setup:** when the install directory is not on PATH, the macOS and Linux installers now add Codewhale's marked three-line PATH block to your shell profile, print exactly which file and lines they added and the command that removes them, and skip this with `--no-modify-path` or `CODEWHALE_NO_MODIFY_PATH=1`; the Windows archive installer adds the directory to your user PATH the same way and skips it with `-NoModifyPath`. Release archives carry this from 0.10.3 on, and the Windows installer has not been run yet. (CW-8036) The website serves install.sh, so this reaches people only when the website is next deployed.
|
||||
|
||||
- **Website and docs:** installation now leads to a version check and a verifiable first task, article search finds text and commands with section links, and GitHub star links show the fetched count. The hero reuses the generated drone whale, and control/tool references distinguish the unpublished source candidate from v0.10.2; these website changes still await deployment.
|
||||
- **External agent options:** `GET /v1/agents/external/{id}/options?cwd=` returns the models, modes and options an external agent itself offers for a folder, and which Codewhale actions it will not carry. A saved agent model the agent no longer offers is refused with a stable `choice_not_offered` code instead of being ignored, per-turn settings an agent cannot honor are refused as `unsupported_control`, and each agent turn records the context window and tokens the agent reported, with cost shown as unknown (CW-7879). Probing starts a real agent session in the folder, which the agent may keep in its own history. Mode and config choices are listed but cannot yet be chosen through thread creation. Verified on Linux by unit and local HTTP tests against a fixture agent; no real installed agent, macOS, Windows or hosted CI run.
|
||||
- **External agent interrupts:** Interrupting a turn while an external agent waits for approval now answers the agent's permission request as cancelled. (CW-7958)
|
||||
- **External agent quota:** The Runtime API route `GET /v1/agents/external/quota` reports each external agent's quota as unknown with a reason (flag off, consent missing, not implemented). The new `agents.quota.<agent>` and `agents.quota.<agent>.consent` settings, saved through `POST /v1/config`, record the opt-in and consent (at most 32 agent entries). No agent credential is read. (CW-7993)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Website design:** codewhale.net now uses the desktop app's 5.3 light and dark colour roles and Geist / Geist Mono, replacing the warm-paper palette and Shannon Sans; the vendored design copy is synced to 5.3.0 and the whale-road ocean bands are unchanged.
|
||||
|
||||
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Plugins, DSH and MCP tools are not yet attached to ACP-driven sessions. A real-editor journey is not yet proved.
|
||||
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
|
||||
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
|
||||
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
|
||||
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
|
||||
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
|
||||
- **Website copy:** The site's headline is now "The model can be anyone's. The agent should be yours.", with its subhead, belief and labelled product map in every language.
|
||||
- **Website routes:** Pages that repeated each other now redirect to the one page that does the job, the header and footer list only pages that exist under their current names, the sitemap drops redirected and catalogue pages, and machine-drafted translations say so.
|
||||
- **Website privacy:** The website no longer counts anything when your browser sends Global Privacy Control or Do Not Track, and the privacy page says exactly what is counted and how it is keyed.
|
||||
- Plugin UI: the Runtime API now serves reviewed DSH client closures on six seats (customize.plugins.detail, conversation.composer.dock, conversation.chat.node, workpane.panel, sidebar.section, command.row) with coded refusals, drops a plugin's UI grants the moment it is disabled, revoked or uninstalled, and adds a plugin route bridge that refuses until a plugin web server exists. No real DSH package has been served end to end yet, and a client that imports `@deepseek-ai/dsh-client-*` modules is still refused. (CW-7956)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
|
||||
- After choosing "explore offline" on first run, connecting a provider or Custom endpoint with /provider now takes effect immediately; messages are no longer held as "Saved for later" until restart, and a message held while offline returns to the composer.
|
||||
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
|
||||
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
|
||||
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
|
||||
- The Runtime API's `POST /v1/git/push` now pushes exactly one branch at one commit (`<commit>:refs/heads/<branch>`, no follow-tags or submodule recursion), so `push.default`, `remote.<name>.push` and `push.followTags` can no longer publish other branches or tags. Clients can bind a push to a named branch and expected HEAD with `branch` + `expect.head` (advertised as `named_push` in `GET /v1/git`); a stale branch or HEAD answers `409 git_state_changed` and pushes nothing. A bare push from a detached HEAD or an unborn branch is now refused, and the desktop app has not yet adopted the named form. Verified on macOS by unit tests against a local bare remote; no real network remote, Linux, Windows or hosted CI run.
|
||||
- Cancelling or timing out a read-only Git tool (git_status, git_diff, git_commit_plan, git_log, git_show, git_blame, git_merge_tree) now stops the git process and anything it started, instead of leaving it running in the background. Verified on macOS by unit tests; Linux, Windows and hosted CI are not yet run, and the filter-override probe is still not contained.
|
||||
- TUI: the launch resume confirmation no longer treats Meta+Enter, Meta+Y or Hyper+Enter as "resume"; only a plain Enter/Y or Shift+Y answers it. Verified by the focused unit tests only; not tried in a terminal that sends Meta or Hyper.
|
||||
- TUI: mentioning a huge directory or a far-away line range (`@file:N-M`) now stops after a bounded scan and says the result is partial instead of claiming end of file. Verified by the focused `file_mention` unit tests; no real 64 MiB file was tried.
|
||||
- Runtime `patch-undo` now restores a turn's own file changes in the default (Ask) posture without trust mode or Full Access; later edits and changes the turn did not make are still refused with nothing changed, and the `restore_requires_trust` error code is gone ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)). Verified by app-server and TUI unit tests on Linux; no Windows `/undo` or process-restart run.
|
||||
- **Website terminal frames:** The site can render terminal frames captured from the released version, and chooses each frame's caption from where it was captured.
|
||||
- **Website docs truth:** Documentation pages now describe the released version first and show what exists only in source in a labelled section that switches on when a release carries it.
|
||||
- **Website Arabic pages:** On Arabic pages, English text that has no translation yet keeps its own direction and language, so punctuation, commands and dates no longer appear reversed.
|
||||
- A DSH plugin whose package includes a `.gitignore` at its root now installs instead of being refused as a hidden file; nested `.gitignore` files, other hidden files, credential files and links are still refused. (CW-8023)
|
||||
- `install.bat` in the Windows release archive now passes its arguments to `install.ps1`, so `install.bat -NoModifyPath` declines the user PATH edit like the PowerShell installer does, and the install guide says so. It has not been run on Windows. The batch file reaches people in the next release archive, and the guide sentence when the website is next deployed, because the site serves the guide but not `install.bat`. (CW-8046)
|
||||
|
||||
## [0.10.3] - 2026-11-08
|
||||
|
||||
@@ -37,11 +78,12 @@ until the matching tag, packages, checksums, and release assets exist.
|
||||
|
||||
### Work in progress and known limits
|
||||
|
||||
This source candidate is not the final 0.10.3 tag. The 2026-11-08 scope
|
||||
decision keeps App Server v2, the coarse Engine split and DSH all-33
|
||||
compatibility in 0.10.3; the final tag waits for that full list. The near-term
|
||||
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
|
||||
tag. Items moved to 0.10.4 by name are marked below.
|
||||
This source candidate is not the final 0.10.3 tag. There is no
|
||||
`0.10.3-beta.2`: the next release is the full 0.10.3, and its final tag waits
|
||||
for the full scope list. Codex App Server v2 is paused and is not part of
|
||||
0.10.3; the legacy JSON-RPC dialect is not retired in this release. The coarse
|
||||
Engine split and DSH all-33 compatibility stay in 0.10.3, and none of this
|
||||
work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
|
||||
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
|
||||
terminal sessions and background jobs owned by that Runtime thread. The
|
||||
@@ -584,7 +626,7 @@ tag. Items moved to 0.10.4 by name are marked below.
|
||||
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
|
||||
- **A failed custom-provider switch leaves the provider you were on.** When saving a custom provider succeeds but switching to it fails (its identity cannot be resolved, or the engine refuses the route), the next request keeps going to the provider the footer shows instead of the half-configured one.
|
||||
- **A zero page size over stdio still advances.** `thread/loaded/list` on the App Server's stdio transport treats a requested limit of `0` as `1`, so a client that sends `0` reaches the next page. `GET /v1/threads` is unchanged.
|
||||
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
|
||||
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did, including when such a tool is forced through an object-form `tool_choice`. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)). Thanks [@ychu824](https://github.com/ychu824) for the report.
|
||||
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
|
||||
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
|
||||
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
|
||||
@@ -1056,6 +1098,7 @@ tag. Items moved to 0.10.4 by name are marked below.
|
||||
- **[@Statter](https://github.com/Statter)** — reported black-on-black text on terminals that do not report their background ([#7043](https://github.com/codewhale-hq/Codewhale/issues/7043)).
|
||||
- **[@eugenicum](https://github.com/eugenicum)** — asked for a visible Full Access flag for `serve --acp` ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992)).
|
||||
- **[@luestr](https://github.com/luestr)** — reported file and conversation rollback diverging ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)), the Linux multiline send key ([#7093](https://github.com/codewhale-hq/Codewhale/issues/7093)) and session export guidance ([#6996](https://github.com/codewhale-hq/Codewhale/issues/6996)).
|
||||
- **[@ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
|
||||
|
||||
## [0.10.2] - 2026-10-22
|
||||
|
||||
|
||||
Generated
+157
-13
@@ -19,6 +19,48 @@ dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32339d83e679c8092ec69d2f18e84448f4acf09b46a753e48f3e9265f920acb1"
|
||||
dependencies = [
|
||||
"agent-client-protocol-derive",
|
||||
"agent-client-protocol-schema",
|
||||
"anyhow",
|
||||
"futures",
|
||||
"futures-concurrency",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol-derive"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05a39fd0d81daab854d25144e82e31006b87c554818beb695f0edb576e9c54c4"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol-schema"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fd595be39fc4f9c8170872e5d10d81b44cb6dca44bcc66daba0d34412d4237d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_more",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -601,6 +643,15 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.0"
|
||||
@@ -882,6 +933,8 @@ dependencies = [
|
||||
name = "codewhale-app-server"
|
||||
version = "0.10.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-schema",
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -966,7 +1019,7 @@ version = "0.10.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"jsonschema",
|
||||
"schemars",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1070,7 +1123,7 @@ dependencies = [
|
||||
"libc",
|
||||
"ring",
|
||||
"rust_decimal",
|
||||
"schemars",
|
||||
"schemars 1.2.2",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1123,6 +1176,8 @@ dependencies = [
|
||||
name = "codewhale-engine"
|
||||
version = "0.10.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"agent-client-protocol-schema",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
@@ -1194,7 +1249,7 @@ dependencies = [
|
||||
"rmcp",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"schemars",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
@@ -1830,7 +1885,7 @@ dependencies = [
|
||||
"rquickjs",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"schemars",
|
||||
"schemars 1.2.2",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2389,6 +2444,9 @@ name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
@@ -2859,6 +2917,19 @@ dependencies = [
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-concurrency"
|
||||
version = "7.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
|
||||
dependencies = [
|
||||
"fixedbitset 0.5.7",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"pin-project",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -3723,7 +3794,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -3750,6 +3821,12 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -4156,6 +4233,17 @@ dependencies = [
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -5346,7 +5434,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset 0.5.7",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6078,13 +6166,13 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"libc",
|
||||
"oauth2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"reqwest",
|
||||
"schemars",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
@@ -6126,7 +6214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a2ea9adb03b669c933482ff740b683cd36be7cef23891657a8ccb850401d286"
|
||||
dependencies = [
|
||||
"convert_case 0.12.0",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6384,6 +6472,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -6553,7 +6653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -6613,6 +6713,39 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.2",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.38"
|
||||
@@ -7265,6 +7398,7 @@ dependencies = [
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7273,6 +7407,16 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
@@ -7414,7 +7558,7 @@ version = "1.1.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
@@ -7447,7 +7591,7 @@ version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -7461,7 +7605,7 @@ version = "0.25.15+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
|
||||
@@ -73,6 +73,8 @@ repository = "https://github.com/codewhale-hq/CodeWhale"
|
||||
warnings = "deny"
|
||||
|
||||
[workspace.dependencies]
|
||||
agent-client-protocol = { version = "=5.2.0", default-features = false }
|
||||
agent-client-protocol-schema = { version = "=1.17.0", default-features = false }
|
||||
anyhow = "1.0.100"
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.5", features = ["json", "ws"] }
|
||||
|
||||
+3
-2
@@ -924,7 +924,7 @@ base_url = "https://api.deepseek.com/beta"
|
||||
# (https://bailian.console.aliyun.com/)
|
||||
#
|
||||
# Token Plan Personal and Team share the same AP-Southeast (Singapore) endpoint.
|
||||
# Available text/coding models: qwen3.8-max, qwen3.8-max-preview, qwen3.7-plus,
|
||||
# Available text/coding models: qwen3.8-max, qwen4-max-preview, qwen3.7-plus,
|
||||
# qwen3.7-max, qwen3.6-flash, deepseek-v4-pro, deepseek-v4-flash-0731, glm-5.2
|
||||
#
|
||||
# Provider aliases: modelstudio-token-plan, modelstudio_token_plan,
|
||||
@@ -944,7 +944,8 @@ base_url = "https://api.deepseek.com/beta"
|
||||
# (https://bailian.console.aliyun.com/)
|
||||
#
|
||||
# Coding Plan uses a separate international endpoint (coding-intl.dashscope).
|
||||
# Same model catalog as the Token Plan.
|
||||
# Same model ids as the Token Plan except qwen4-max-preview, which is
|
||||
# Token Plan only.
|
||||
#
|
||||
# Provider aliases: modelstudio-coding-plan, modelstudio_coding_plan,
|
||||
# alibaba-coding-plan, dashscope-coding-plan
|
||||
|
||||
@@ -391,6 +391,11 @@
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_fleet_member_durable_operations_v1": {
|
||||
"type": "boolean",
|
||||
"description": "Member starts are durable per `(store, thread, operation_key)`;\n`GET /v1/threads/{id}/members/operations/{operation_key}` reads them.",
|
||||
"default": false
|
||||
},
|
||||
"fleet_run_start": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
@@ -482,6 +487,7 @@
|
||||
"worker_runtime",
|
||||
"fleet_run_create",
|
||||
"thread_fleet_members",
|
||||
"thread_fleet_member_durable_operations_v1",
|
||||
"fleet_run_start",
|
||||
"fleet_event_replay",
|
||||
"fleet_event_stream",
|
||||
|
||||
@@ -86,6 +86,7 @@ export type RuntimeCapabilities = {
|
||||
worker_runtime: boolean
|
||||
fleet_run_create: boolean
|
||||
thread_fleet_members: boolean
|
||||
thread_fleet_member_durable_operations_v1: boolean
|
||||
fleet_run_start: boolean
|
||||
fleet_event_replay: boolean
|
||||
fleet_event_stream: boolean
|
||||
|
||||
@@ -173,6 +173,10 @@ pub struct RuntimeCapabilities {
|
||||
pub fleet_run_create: bool,
|
||||
#[serde(default)]
|
||||
pub thread_fleet_members: bool,
|
||||
/// Member starts are durable per `(store, thread, operation_key)`;
|
||||
/// `GET /v1/threads/{id}/members/operations/{operation_key}` reads them.
|
||||
#[serde(default)]
|
||||
pub thread_fleet_member_durable_operations_v1: bool,
|
||||
#[serde(default)]
|
||||
pub fleet_run_start: bool,
|
||||
#[serde(default)]
|
||||
@@ -550,6 +554,7 @@ mod tests {
|
||||
worker_runtime: false,
|
||||
fleet_run_create: true,
|
||||
thread_fleet_members: true,
|
||||
thread_fleet_member_durable_operations_v1: true,
|
||||
fleet_run_start: true,
|
||||
fleet_event_replay: true,
|
||||
fleet_event_stream: true,
|
||||
|
||||
@@ -26,6 +26,7 @@ default = []
|
||||
test-support = ["codewhale-engine/test-support"]
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
|
||||
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
|
||||
anyhow.workspace = true
|
||||
@@ -94,6 +95,7 @@ urlencoding = "2.1"
|
||||
webbrowser = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
agent-client-protocol-schema.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tempfile.workspace = true
|
||||
tower = "0.5"
|
||||
|
||||
@@ -54,7 +54,7 @@ pub async fn run_acp_server(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) struct CapturedAcpFrontend {
|
||||
pub struct CapturedAcpFrontend {
|
||||
config: Config,
|
||||
model: String,
|
||||
cwd: PathBuf,
|
||||
@@ -63,7 +63,7 @@ pub(crate) struct CapturedAcpFrontend {
|
||||
config_path: Option<PathBuf>,
|
||||
config_profile: Option<String>,
|
||||
}
|
||||
pub(crate) fn capture_frontend(
|
||||
pub fn capture_frontend(
|
||||
config: Config,
|
||||
model: String,
|
||||
cwd: PathBuf,
|
||||
@@ -84,7 +84,7 @@ pub(crate) fn capture_frontend(
|
||||
}))
|
||||
}
|
||||
impl CapturedAcpFrontend {
|
||||
pub(crate) fn serve<'a>(
|
||||
pub fn serve<'a>(
|
||||
&'a self,
|
||||
input: Box<dyn AsyncBufRead + Send + Unpin>,
|
||||
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
|
||||
@@ -385,6 +385,11 @@ impl AcpServer {
|
||||
if !self.sessions.contains_key(id) {
|
||||
return Err(AcpError::invalid_params("unknown sessionId"));
|
||||
}
|
||||
if let Some(kind) = unsupported_prompt_block(params.get("prompt")) {
|
||||
return Err(AcpError::invalid_params(format!(
|
||||
"unsupported prompt content block `{kind}`: this agent accepts text, resource and resource_link blocks only"
|
||||
)));
|
||||
}
|
||||
let prompt = extract_prompt_text(params.get("prompt"))
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?;
|
||||
@@ -407,11 +412,7 @@ impl AcpServer {
|
||||
}
|
||||
async fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
|
||||
validate_mcp_servers(¶ms)?;
|
||||
let cwd = params
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| self.default_cwd.clone());
|
||||
let cwd = absolute_cwd(¶ms, "session/new")?.unwrap_or_else(|| self.default_cwd.clone());
|
||||
let identity = self
|
||||
.config
|
||||
.active_provider_identity()
|
||||
@@ -506,6 +507,7 @@ impl AcpServer {
|
||||
|
||||
async fn load_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
|
||||
validate_mcp_servers(¶ms)?;
|
||||
absolute_cwd(¶ms, "session/load")?;
|
||||
let requested = params
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
@@ -971,7 +973,8 @@ fn tool_call_kind(call: &PendingToolCall) -> &'static str {
|
||||
Some("write" | "edit" | "patch") => "edit",
|
||||
_ => "read",
|
||||
},
|
||||
"apply_patch" => "edit",
|
||||
"apply_patch" | "write" | "edit" | "write_file" | "edit_file" => "edit",
|
||||
"read" | "read_file" => "read",
|
||||
"Git" => "read",
|
||||
"bash" | "Bash" | "terminal/run" | "terminal/send" | "terminal/wait"
|
||||
| "terminal/cancel" | "terminal/reset" => "execute",
|
||||
@@ -1196,6 +1199,31 @@ fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_prompt_block(prompt: Option<&Value>) -> Option<String> {
|
||||
let Value::Array(blocks) = prompt? else {
|
||||
return None;
|
||||
};
|
||||
blocks.iter().find_map(|block| {
|
||||
let kind = block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
content_block_text(block)
|
||||
.is_none()
|
||||
.then(|| kind.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn absolute_cwd(params: &Value, method: &str) -> std::result::Result<Option<PathBuf>, AcpError> {
|
||||
match params.get("cwd") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(path)) if Path::new(path).is_absolute() => Ok(Some(PathBuf::from(path))),
|
||||
Some(_) => Err(AcpError::invalid_params(format!(
|
||||
"{method} cwd must be an absolute path"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn content_block_text(block: &Value) -> Option<String> {
|
||||
match block.get("type").and_then(Value::as_str)? {
|
||||
"text" => block
|
||||
|
||||
@@ -114,9 +114,11 @@ mod account;
|
||||
mod auth;
|
||||
mod automations;
|
||||
mod avatars;
|
||||
mod builtin_commands;
|
||||
mod computer_display;
|
||||
mod context;
|
||||
mod diagnostics;
|
||||
mod external_agent_options;
|
||||
mod external_agents;
|
||||
mod git;
|
||||
mod jobs;
|
||||
@@ -1619,6 +1621,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
|
||||
worker_runtime: true,
|
||||
fleet_run_create: true,
|
||||
thread_fleet_members: true,
|
||||
thread_fleet_member_durable_operations_v1: true,
|
||||
fleet_run_start: true,
|
||||
fleet_event_replay: true,
|
||||
fleet_event_stream: true,
|
||||
@@ -3128,7 +3131,12 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/threads/{id}/context", get(context::get_thread_context))
|
||||
.route(
|
||||
"/v1/threads/{id}/members",
|
||||
post(members::start_thread_member),
|
||||
post(members::start_thread_member)
|
||||
.layer(DefaultBodyLimit::max(members::MAX_MEMBER_REQUEST_BYTES)),
|
||||
)
|
||||
.route(
|
||||
"/v1/threads/{id}/members/operations/{operation_key}",
|
||||
get(members::get_member_operation),
|
||||
)
|
||||
.route("/v1/threads/{id}/plan", get(plans::get_thread_plan))
|
||||
.route("/v1/threads/{id}/todo", get(plans::get_thread_todo))
|
||||
@@ -3277,6 +3285,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/skills", get(list_skills))
|
||||
.route("/v1/commands", get(list_commands))
|
||||
.route("/v1/threads/{id}/commands", post(run_thread_command))
|
||||
.route(
|
||||
"/v1/threads/{id}/commands/builtin",
|
||||
post(builtin_commands::run_builtin_command),
|
||||
)
|
||||
.route(
|
||||
"/v1/threads/{id}/commands/{operation_key}",
|
||||
get(get_thread_command).delete(cancel_thread_command),
|
||||
@@ -3368,6 +3380,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
"/v1/apps/plugins/{selector}/ui/{*path}",
|
||||
get(plugins::get_plugin_ui_asset),
|
||||
)
|
||||
.route(
|
||||
"/v1/apps/plugins/{selector}/routes/{*path}",
|
||||
any(plugins::plugin_route_bridge),
|
||||
)
|
||||
.route(
|
||||
"/v1/apps/plugins/{selector}/update",
|
||||
post(plugins::update_plugin_api),
|
||||
@@ -3462,6 +3478,14 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
"/v1/agents/external",
|
||||
get(external_agents::list_external_agents),
|
||||
)
|
||||
.route(
|
||||
"/v1/agents/external/quota",
|
||||
get(external_agents::get_external_agent_quota),
|
||||
)
|
||||
.route(
|
||||
"/v1/agents/external/{id}/options",
|
||||
get(external_agent_options::get_external_agent_options),
|
||||
)
|
||||
.route("/v1/providers", get(list_providers))
|
||||
.route("/v1/providers/{id}/models", get(list_provider_models))
|
||||
.route(
|
||||
@@ -9211,14 +9235,13 @@ async fn preview_patch_undo_thread_turn(
|
||||
.iter()
|
||||
.map(|turn| turn.turn_id.clone())
|
||||
.collect();
|
||||
let trusted = thread.trust_mode || thread.auto_approve;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
|
||||
let (result, paths) = tokio::task::spawn_blocking(move || {
|
||||
let _reservation = _reservation;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
|
||||
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, trusted, true, None)
|
||||
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, true, None)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiError::internal(format!("Patch undo preview failed: {error}")))??;
|
||||
@@ -9255,10 +9278,6 @@ async fn patch_undo_thread_turn(
|
||||
// Validate the selected turn/history before touching any file, so an invalid
|
||||
// undo request cannot leave a half-applied workspace.
|
||||
let prepared = prepare_patch_undo(&state, &id, &req).await?;
|
||||
// File rollback is a workspace mutation, so it needs the trust the
|
||||
// TUI's `/undo` requires. Read from the thread's own record: the
|
||||
// client does not get to assert it.
|
||||
let trusted = thread.trust_mode || thread.auto_approve;
|
||||
let workspace = thread.workspace.clone();
|
||||
// The restore points come from the dropped turns' own records, not
|
||||
// from the thread's saved-session binding or a scan of the shared
|
||||
@@ -9270,7 +9289,7 @@ async fn patch_undo_thread_turn(
|
||||
let patch_result = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
|
||||
patch_undo_workspace_files_impl(&workspace, &dropped_turns, trusted, false, req.expected_paths.as_deref()).map(|(result, _)| result)
|
||||
patch_undo_workspace_files_impl(&workspace, &dropped_turns, false, req.expected_paths.as_deref()).map(|(result, _)| result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))??;
|
||||
@@ -9313,7 +9332,6 @@ const PATCH_UNDO_NO_RESTORE_POINT: &str = "restore_point_unavailable";
|
||||
const PATCH_UNDO_RESTORE_POINT_PRUNED: &str = "restore_point_pruned";
|
||||
const PATCH_UNDO_PATH_NOT_SNAPSHOTTED: &str = "path_not_snapshotted";
|
||||
const PATCH_UNDO_WORKSPACE_CHANGED: &str = "workspace_changed_since_turn";
|
||||
const PATCH_UNDO_UNTRUSTED: &str = "restore_requires_trust";
|
||||
const PATCH_UNDO_WORKSPACE_UNAVAILABLE: &str = "workspace_unavailable";
|
||||
|
||||
/// One pre-turn → post-turn window of a dropped turn, resolved to the trees
|
||||
@@ -9545,21 +9563,23 @@ fn no_restore_point(turn_id: &str, why: &str) -> ApiError {
|
||||
/// point, or a path changed since the turn is a `409` with a stable
|
||||
/// `error.code`, never a `201` that forks while the files stay changed.
|
||||
///
|
||||
/// `trusted` mirrors the gate the TUI's `patch_undo()` applies
|
||||
/// (`yolo || trust_mode`), evaluated once a real change is known.
|
||||
/// # Posture
|
||||
///
|
||||
/// No trust mode or Full Access is required. Undo reverses only what the
|
||||
/// dropped turns' own tool calls wrote, and only while every such path still
|
||||
/// holds exactly the bytes the turn left; the same ownership and later-edit
|
||||
/// proofs refuse anything else, so the default posture cannot lose work the
|
||||
/// turn did not make.
|
||||
pub fn patch_undo_workspace_files(
|
||||
workspace: &FsPath,
|
||||
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
|
||||
trusted: bool,
|
||||
) -> Result<PatchUndoResult, ApiError> {
|
||||
patch_undo_workspace_files_impl(workspace, dropped_turns, trusted, false, None)
|
||||
.map(|(result, _)| result)
|
||||
patch_undo_workspace_files_impl(workspace, dropped_turns, false, None).map(|(result, _)| result)
|
||||
}
|
||||
|
||||
fn patch_undo_workspace_files_impl(
|
||||
workspace: &FsPath,
|
||||
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
|
||||
trusted: bool,
|
||||
preview: bool,
|
||||
expected_paths: Option<&[String]>,
|
||||
) -> Result<(PatchUndoResult, Vec<String>), ApiError> {
|
||||
@@ -9816,17 +9836,6 @@ fn patch_undo_workspace_files_impl(
|
||||
));
|
||||
}
|
||||
|
||||
// Restoring is a workspace mutation. Gate it exactly where the TUI gates
|
||||
// it — after a real, owned change is known — so the two surfaces cannot
|
||||
// drift into "one refuses, the other half-undoes".
|
||||
if !trusted {
|
||||
return Err(ApiError::conflict(
|
||||
"Refusing to undo workspace files outside trusted mode. \
|
||||
Turn on /trust or switch this thread to Full Access, then undo again.",
|
||||
)
|
||||
.with_code(PATCH_UNDO_UNTRUSTED));
|
||||
}
|
||||
|
||||
let paths: Vec<String> = to_restore
|
||||
.iter()
|
||||
.map(|(path, _, _)| path.display().to_string())
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! `POST /v1/threads/{id}/commands/builtin` — execute a terminal built-in
|
||||
//! command against a thread, remotely.
|
||||
//!
|
||||
//! The thread's state is assembled into the engine's
|
||||
//! [`BuiltinCommandHostSnapshot`] (contract projections included) and handed
|
||||
//! to the installed command catalog, whose terminal-host implementation maps
|
||||
//! it into the facet envelope and runs the portable handlers. Which commands
|
||||
//! this serves is the host's decision (`builtin_execution::SERVED`); a name
|
||||
//! outside that set answers 404 the same way an unknown name does, so the
|
||||
//! remote surface can never widen without the host widening it first.
|
||||
//!
|
||||
//! Phase 1 notes: executions are not receipt-tracked (the phase-1 set is
|
||||
//! read-only — every served handler is pure over the snapshot), and the
|
||||
//! `text_only` flag reports commands that also asked the terminal host for an
|
||||
//! action, which a remote client cannot perform.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use codewhale_command_contract::facets::{
|
||||
DebugCostProjection, DebugRouteCost, DebugSystemPrompt, DebugTokenProjection,
|
||||
};
|
||||
use codewhale_command_contract::types::{CommandCurrency, CommandMode};
|
||||
use codewhale_engine::command_catalog::{self, BuiltinCommandHostSnapshot};
|
||||
use codewhale_models::SystemPrompt;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct BuiltinCommandApiRequest {
|
||||
/// The command name (canonical or alias), with or without the slash.
|
||||
pub name: String,
|
||||
/// The argument tail after the command word, if any.
|
||||
#[serde(default)]
|
||||
pub args: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(super) struct BuiltinCommandApiResponse {
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub is_error: bool,
|
||||
/// `false` when the command also asked the terminal host for an action
|
||||
/// the remote client cannot perform.
|
||||
pub text_only: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn run_builtin_command(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(thread_id): Path<String>,
|
||||
Json(request): Json<BuiltinCommandApiRequest>,
|
||||
) -> Result<Json<BuiltinCommandApiResponse>, ApiError> {
|
||||
let catalog = command_catalog::installed()
|
||||
.ok_or_else(|| ApiError::internal("command catalog is not installed"))?;
|
||||
// Refuse a name outside the host's remote surface before reading any
|
||||
// thread state: an unknown command and a known-but-unserved one get the
|
||||
// same answer, and neither can be distinguished by what the read says
|
||||
// about the daemon's idle threads.
|
||||
if !catalog.serves_builtin(&request.name) {
|
||||
return Err(not_served(&request.name));
|
||||
}
|
||||
|
||||
let thread = state
|
||||
.runtime_threads
|
||||
.get_thread(&thread_id)
|
||||
.await
|
||||
.map_err(super::map_thread_err)?;
|
||||
|
||||
let snapshot = assemble_snapshot(&state, &thread_id, &thread).await?;
|
||||
|
||||
match catalog.execute_builtin(&request.name, request.args.as_deref(), &snapshot) {
|
||||
Some(execution) => Ok(Json(BuiltinCommandApiResponse {
|
||||
name: execution.name,
|
||||
text: execution.text,
|
||||
is_error: execution.is_error,
|
||||
text_only: execution.text_only,
|
||||
})),
|
||||
// The host refused the name: the same answer an unknown command gets.
|
||||
None => Err(not_served(&request.name)),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_served(name: &str) -> ApiError {
|
||||
const MAX_ECHOED_NAME_CHARS: usize = 64;
|
||||
let shown: String = name.chars().take(MAX_ECHOED_NAME_CHARS).collect();
|
||||
let ellipsis = if shown.len() < name.len() { "..." } else { "" };
|
||||
ApiError::not_found(format!(
|
||||
"no remotely executable builtin command '{shown}{ellipsis}'"
|
||||
))
|
||||
}
|
||||
|
||||
/// Assemble the host snapshot for one thread from the record, the live
|
||||
/// engine's context budget, the usage aggregate, and the canonical session
|
||||
/// document. Every projection field is filled from measured data or left at
|
||||
/// its honest absence encoding; nothing is estimated into place.
|
||||
async fn assemble_snapshot(
|
||||
state: &RuntimeApiState,
|
||||
thread_id: &str,
|
||||
thread: &codewhale_engine::runtime_threads::ThreadRecord,
|
||||
) -> Result<BuiltinCommandHostSnapshot, ApiError> {
|
||||
// The live engine carries the context budget; an idle daemon thread has
|
||||
// no honest context estimate, so the served diagnostics refuse rather
|
||||
// than print zeros.
|
||||
let engine = state
|
||||
.runtime_threads
|
||||
.loaded_engine(thread_id)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
ApiError::conflict("this thread's engine is not loaded; send a message first")
|
||||
})?;
|
||||
let budget = engine
|
||||
.get_context_budget()
|
||||
.await
|
||||
.map_err(super::map_thread_err)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::conflict("this thread has no live context estimate yet; send a message first")
|
||||
})?;
|
||||
|
||||
let usage = state
|
||||
.runtime_threads
|
||||
.aggregate_usage_for_thread(thread_id)
|
||||
.await
|
||||
.map_err(super::map_thread_err)?;
|
||||
let parent = &usage.parent;
|
||||
let children = &usage.routed_children;
|
||||
let combined = usage.combined();
|
||||
|
||||
// The daemon tracks no UI chat history (that is the client's layer), so
|
||||
// the chat count is a true zero; the model transcript and the effective
|
||||
// system prompt come from the live engine session.
|
||||
let session = engine
|
||||
.get_session_snapshot()
|
||||
.await
|
||||
.map_err(|err| ApiError::internal(format!("engine session snapshot unavailable: {err}")))?;
|
||||
let api_message_count = session.messages.len();
|
||||
let chat_message_count = 0;
|
||||
|
||||
// Currency follows the same rule the TUI's cost display applies, so the
|
||||
// same config answers the same way on both surfaces: a Simplified-Chinese
|
||||
// UI reading the default "usd" shows CNY, and every other spelling goes
|
||||
// through the canonical parser (which accepts rmb and yuan) rather than a
|
||||
// bare string compare. The CNY column is the provider-published subtotal
|
||||
// only; a USD-picked currency keeps the authoritative USD numbers.
|
||||
#[cfg(test)]
|
||||
let env_ticket = codewhale_runtime::test_support::env_scope_ticket();
|
||||
let settings = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
let _membership = codewhale_runtime::test_support::join_env_scope(env_ticket);
|
||||
codewhale_runtime::settings::Settings::load_read_only()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| ApiError::internal(format!("settings task failed: {err}")))?
|
||||
.map_err(|err| ApiError::internal(format!("settings unavailable: {err}")))?;
|
||||
let locale = codewhale_localization::resolve_locale(&settings.locale);
|
||||
let currency = match (settings.cost_currency.as_str(), locale.tag()) {
|
||||
("usd", "zh-Hans") => CommandCurrency::Cny,
|
||||
_ => match codewhale_config::pricing::CostCurrency::from_setting(&settings.cost_currency) {
|
||||
Some(codewhale_config::pricing::CostCurrency::Cny) => CommandCurrency::Cny,
|
||||
_ => CommandCurrency::Usd,
|
||||
},
|
||||
};
|
||||
let currency_amount =
|
||||
|totals: &codewhale_engine::runtime_threads::manager::UsageTotals| match currency {
|
||||
CommandCurrency::Cny => totals.cost_cny,
|
||||
CommandCurrency::Usd => totals.cost_usd,
|
||||
};
|
||||
let parent_amount = currency_amount(parent);
|
||||
let children_amount = currency_amount(children);
|
||||
let total_amount = currency_amount(&combined);
|
||||
let (priced_turns, unpriced_turns, unpriced_reasons) = match currency {
|
||||
CommandCurrency::Cny => (
|
||||
combined.cny_priced_turns,
|
||||
combined.cny_unpriced_turns,
|
||||
&combined.cny_unpriced_reasons,
|
||||
),
|
||||
CommandCurrency::Usd => (
|
||||
combined.priced_turns,
|
||||
combined.unpriced_turns,
|
||||
&combined.unpriced_reasons,
|
||||
),
|
||||
};
|
||||
|
||||
let mode = CommandMode::parse(&session.mode).unwrap_or(CommandMode::Agent);
|
||||
let mode_label = codewhale_config::AppMode::from_setting(&session.mode)
|
||||
.label()
|
||||
.to_string();
|
||||
let system_prompt = match session.system_prompt {
|
||||
None => DebugSystemPrompt::None,
|
||||
Some(SystemPrompt::Text(text)) => DebugSystemPrompt::Text(text),
|
||||
Some(SystemPrompt::Blocks(blocks)) => {
|
||||
DebugSystemPrompt::Blocks(blocks.into_iter().map(|block| block.text).collect())
|
||||
}
|
||||
};
|
||||
|
||||
let cost = DebugCostProjection {
|
||||
currency,
|
||||
// The combined subtotal is the authoritative total, and it is by
|
||||
// construction the parent and children amounts added up; the
|
||||
// terminal's #244 reconciliation floor only exists because its
|
||||
// display total and its recomputed components can drift apart. The
|
||||
// remote aggregate cannot drift from itself, so there is no floor to
|
||||
// report and none is invented.
|
||||
total: total_amount,
|
||||
parent_turns: parent_amount,
|
||||
subagents: children_amount,
|
||||
display_floor: 0.0,
|
||||
priced_turns: priced_turns.clamp(0, u32::MAX as u64) as u32,
|
||||
unpriced_turns: unpriced_turns.clamp(0, u32::MAX as u64) as u32,
|
||||
legacy_coverage_unknown: !combined.cost_complete,
|
||||
// Pricing-provenance bookkeeping (user-declared estimates, live
|
||||
// defects, route receipts) is terminal-session state the daemon
|
||||
// does not track; the honest encoding is "none recorded".
|
||||
user_declared_estimates: false,
|
||||
// Per-route attribution needs per-turn provider identities, which
|
||||
// the remote snapshot does not assemble yet; the coverage counts
|
||||
// above stay authoritative. With no route rows, no turn is itemized:
|
||||
// claiming otherwise would make the renderer state a breakdown it
|
||||
// cannot show.
|
||||
itemized_turns: 0,
|
||||
route_amounts: Vec::<DebugRouteCost>::new(),
|
||||
turn_history_capacity: 0,
|
||||
unpriced_reason_labels: unpriced_reasons.iter().cloned().collect(),
|
||||
unpriced_classes: combined.unpriced_classes.iter().cloned().collect(),
|
||||
pricing_provenances: Vec::new(),
|
||||
live_pricing_defects: Vec::new(),
|
||||
unusable_pricing_defects: Vec::new(),
|
||||
route_receipts: Vec::new(),
|
||||
};
|
||||
|
||||
Ok(BuiltinCommandHostSnapshot {
|
||||
mode,
|
||||
mode_label,
|
||||
locale,
|
||||
model: thread.model.clone(),
|
||||
system_prompt,
|
||||
tokens: DebugTokenProjection {
|
||||
active_context_used: (budget.input_tokens as usize).min(budget.window_tokens as usize),
|
||||
context_window: budget.window_tokens.clamp(0, u32::MAX as u64) as u32,
|
||||
// The context budget carries no per-turn telemetry, so the
|
||||
// last-turn columns take their honest absence encoding rather
|
||||
// than a stale or fabricated value.
|
||||
last_input: None,
|
||||
last_output: None,
|
||||
cache_hit: None,
|
||||
cache_miss: None,
|
||||
total_tokens: combined.input_tokens.saturating_add(combined.output_tokens),
|
||||
cache_write_tokens: combined.cache_write_tokens,
|
||||
api_message_count,
|
||||
chat_message_count,
|
||||
model: thread.model.clone(),
|
||||
cost,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
|
||||
use codewhale_engine::fleet::acp::AgentOptions;
|
||||
use codewhale_engine::fleet::agents::ExecutorUnavailable;
|
||||
|
||||
use super::{ApiError, RuntimeApiState, external_agent_refusal};
|
||||
|
||||
const MAX_AGENT_ID_BYTES: usize = 64;
|
||||
const MAX_CWD_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct OptionsQuery {
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn valid_agent_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= MAX_AGENT_ID_BYTES
|
||||
&& id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
|
||||
}
|
||||
|
||||
pub(super) async fn get_external_agent_options(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<OptionsQuery>,
|
||||
) -> Result<Json<AgentOptions>, ApiError> {
|
||||
if !valid_agent_id(&id) {
|
||||
return Err(ApiError::bad_request(
|
||||
"the agent id must name an agent from GET /v1/agents/external",
|
||||
));
|
||||
}
|
||||
let cwd = query
|
||||
.cwd
|
||||
.ok_or_else(|| ApiError::bad_request("cwd is required"))?;
|
||||
let text = cwd.to_string_lossy();
|
||||
if text.len() > MAX_CWD_BYTES || text.chars().any(char::is_control) || !cwd.is_absolute() {
|
||||
return Err(ApiError::bad_request(
|
||||
"cwd must be an absolute directory path",
|
||||
));
|
||||
}
|
||||
let probe = cwd.clone();
|
||||
let is_dir = tokio::task::spawn_blocking(move || probe.is_dir())
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("cwd check failed"))?;
|
||||
if !is_dir {
|
||||
return Err(ApiError::not_found(
|
||||
"cwd is not a directory on this computer",
|
||||
));
|
||||
}
|
||||
match state
|
||||
.runtime_threads
|
||||
.external_agent_options(&id, &cwd)
|
||||
.await
|
||||
{
|
||||
Ok(options) => Ok(Json(options)),
|
||||
Err(error) => {
|
||||
let mut refusal = external_agent_refusal(&error);
|
||||
if let Some(refusal) = refusal.as_mut()
|
||||
&& refusal.code == Some(ExecutorUnavailable::UNKNOWN_AGENT)
|
||||
{
|
||||
refusal.status = axum::http::StatusCode::NOT_FOUND;
|
||||
}
|
||||
Err(refusal.unwrap_or_else(|| ApiError::internal(error.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@ const PROBE_REAP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const SILENT_HANDSHAKE: &str = "It did not answer the Agent Client Protocol handshake.";
|
||||
|
||||
use codewhale_engine::fleet::agents::{
|
||||
AGENTS, ExecutorUnavailable, ExternalAgent, below_minimum, cli_version, foreign_command,
|
||||
not_installed, resolve,
|
||||
AGENTS, AgentQuota, ExecutorUnavailable, ExternalAgent, QuotaGate, QuotaSource, QuotaUnknown,
|
||||
below_minimum, cli_version, foreign_command, local_signin_quota, not_installed, resolve,
|
||||
};
|
||||
use codewhale_runtime::process_tree::ProcessTree;
|
||||
use codewhale_runtime::settings::{AgentSettings, Settings};
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Handshake {
|
||||
@@ -274,6 +275,75 @@ pub(super) async fn list_external_agents() -> Json<Value> {
|
||||
Json(json!({ "agents": found }))
|
||||
}
|
||||
|
||||
fn quota_json(quota: &AgentQuota) -> Value {
|
||||
match quota {
|
||||
AgentQuota::Known { source, windows } => json!({
|
||||
"status": "known",
|
||||
"source": source.as_str(),
|
||||
"windows": windows
|
||||
.iter()
|
||||
.map(|window| json!({
|
||||
"name": window.name,
|
||||
"used_percent": window.used_percent,
|
||||
"resets_at": window.resets_at,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
AgentQuota::Unknown(reason) => json!({
|
||||
"status": "unknown",
|
||||
"reason": reason.as_str(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_quota_report(agents: &AgentSettings) -> Value {
|
||||
let mut entries = Vec::with_capacity(AGENTS.len());
|
||||
for agent in AGENTS {
|
||||
let choice = agents.quota(agent.id);
|
||||
let gate = QuotaGate {
|
||||
enabled: choice.enabled,
|
||||
consented: choice.consented_at.is_some(),
|
||||
};
|
||||
let quota = local_signin_quota(agent, gate).await;
|
||||
entries.push(json!({
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"quota": quota_json("a),
|
||||
}));
|
||||
}
|
||||
for id in agents
|
||||
.quota
|
||||
.keys()
|
||||
.filter(|id| !AGENTS.iter().any(|agent| agent.id == id.as_str()))
|
||||
{
|
||||
entries.push(json!({
|
||||
"id": id,
|
||||
"name": null,
|
||||
"quota": quota_json(&AgentQuota::Unknown(QuotaUnknown::UnknownAgent)),
|
||||
}));
|
||||
}
|
||||
json!({
|
||||
"default_source": QuotaSource::AgentReported.as_str(),
|
||||
"agents": entries,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn get_external_agent_quota() -> Json<Value> {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
|
||||
let agents = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
|
||||
Settings::load_read_only()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.map(|settings| settings.agents)
|
||||
.unwrap_or_default();
|
||||
Json(agent_quota_report(&agents).await)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,12 +3,20 @@ use std::time::Duration;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use codewhale_engine::runtime_threads::job_operations::{
|
||||
JobOperationTransaction, MEMBER_ABANDONED, MEMBER_ACCEPTED, MEMBER_RESERVED, MEMBER_UNKNOWN,
|
||||
MemberOperation, MemberOperationRecord, member_request_fingerprint, new_member_agent_id,
|
||||
valid_member_key,
|
||||
};
|
||||
use codewhale_engine::tools::subagent::AgentWorkerRecord;
|
||||
|
||||
use super::jobs::operation_error;
|
||||
use super::{ApiError, RuntimeApiState, map_thread_err};
|
||||
|
||||
const MEMBER_START_WAIT: Duration = Duration::from_secs(20);
|
||||
pub(super) const MAX_MEMBER_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_MEMBER_PROMPT_CHARS: usize = 256_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -19,28 +27,148 @@ pub(super) struct StartMemberRequest {
|
||||
display_name: String,
|
||||
prompt: String,
|
||||
fleet: codewhale_engine::fleet::store::FleetFile,
|
||||
#[serde(default)]
|
||||
expected_store_id: Option<String>,
|
||||
#[serde(default)]
|
||||
request_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
fn opaque(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(super) struct MemberReceipt {
|
||||
thread_id: String,
|
||||
member_id: String,
|
||||
operation_key: String,
|
||||
request_fingerprint: String,
|
||||
runtime_store_id: String,
|
||||
agent_id: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
pub(super) async fn start_thread_member(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(thread_id): Path<String>,
|
||||
Json(request): Json<StartMemberRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||
let display_name = request.display_name.trim();
|
||||
if !opaque(&request.operation_key) || !opaque(&request.member_id) {
|
||||
impl MemberReceipt {
|
||||
fn new(record: &MemberOperationRecord, state: &str) -> Self {
|
||||
Self {
|
||||
thread_id: record.operation.thread_id.clone(),
|
||||
member_id: record.operation.member_id.clone(),
|
||||
operation_key: record.operation.operation_key.clone(),
|
||||
request_fingerprint: record.request_fingerprint.clone(),
|
||||
runtime_store_id: record.operation.store_id.clone(),
|
||||
agent_id: record.operation.agent_id.clone(),
|
||||
state: state.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> StatusCode {
|
||||
if self.state == MEMBER_ACCEPTED {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_proves(operation: &MemberOperation, worker: Option<&AgentWorkerRecord>) -> bool {
|
||||
worker.is_some_and(|worker| {
|
||||
worker.owner_session_id == operation.thread_id
|
||||
&& worker.spec.worker_id == operation.agent_id
|
||||
&& worker
|
||||
.spec
|
||||
.child_route
|
||||
.as_ref()
|
||||
.and_then(|route| route.requested_profile.as_deref())
|
||||
== Some(operation.member_id.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn project(
|
||||
transaction: &JobOperationTransaction<'_>,
|
||||
record: &mut MemberOperationRecord,
|
||||
worker: Option<&AgentWorkerRecord>,
|
||||
generation: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let proven = worker_proves(&record.operation, worker);
|
||||
match record.operation.state.as_str() {
|
||||
MEMBER_ACCEPTED if proven => Ok(MEMBER_ACCEPTED.to_string()),
|
||||
MEMBER_ACCEPTED => Ok(MEMBER_UNKNOWN.to_string()),
|
||||
MEMBER_RESERVED if proven => {
|
||||
settle(transaction, record, MEMBER_ACCEPTED)?;
|
||||
Ok(MEMBER_ACCEPTED.to_string())
|
||||
}
|
||||
MEMBER_RESERVED if record.owner_generation != generation => {
|
||||
settle(transaction, record, MEMBER_ABANDONED)?;
|
||||
Ok(MEMBER_ABANDONED.to_string())
|
||||
}
|
||||
MEMBER_RESERVED => Ok(MEMBER_UNKNOWN.to_string()),
|
||||
state => Ok(state.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(
|
||||
transaction: &JobOperationTransaction<'_>,
|
||||
record: &mut MemberOperationRecord,
|
||||
state: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
record.operation.state = state.to_string();
|
||||
record.operation.updated_at_ms = chrono::Utc::now()
|
||||
.timestamp_millis()
|
||||
.max(record.operation.created_at_ms);
|
||||
transaction.save_member(record)
|
||||
}
|
||||
|
||||
async fn member_worker(
|
||||
state: &RuntimeApiState,
|
||||
thread_id: &str,
|
||||
agent_id: &str,
|
||||
) -> Option<AgentWorkerRecord> {
|
||||
state
|
||||
.sub_agent_manager
|
||||
.read()
|
||||
.await
|
||||
.get_worker_record_for_session(thread_id, agent_id)
|
||||
}
|
||||
|
||||
async fn settle_outcome(
|
||||
state: RuntimeApiState,
|
||||
operation: &MemberOperation,
|
||||
outcome: Option<Result<String, String>>,
|
||||
) -> Result<(), ApiError> {
|
||||
let worker = match &outcome {
|
||||
Some(Ok(agent_id)) => member_worker(&state, &operation.thread_id, agent_id).await,
|
||||
_ => None,
|
||||
};
|
||||
let id = operation.id.clone();
|
||||
let store = state.runtime_threads.job_operation_store();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
store.with_job_operations(|transaction| {
|
||||
let Some(mut record) = transaction.get_member(&id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
if record.operation.state != MEMBER_RESERVED {
|
||||
return Ok(());
|
||||
}
|
||||
match outcome {
|
||||
Some(Ok(agent_id))
|
||||
if agent_id == record.operation.agent_id
|
||||
&& worker_proves(&record.operation, worker.as_ref()) =>
|
||||
{
|
||||
settle(transaction, &mut record, MEMBER_ACCEPTED)
|
||||
}
|
||||
Some(Err(_)) => transaction.remove_member(&id),
|
||||
_ => Ok(()),
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("member operation settlement failed"))?
|
||||
.map_err(operation_error)
|
||||
}
|
||||
|
||||
fn validate(request: &StartMemberRequest) -> Result<(String, String), ApiError> {
|
||||
if !valid_member_key(&request.operation_key) || !valid_member_key(&request.member_id) {
|
||||
return Err(ApiError::bad_request(
|
||||
"operation_key and member_id must be 1-128 URL-safe characters",
|
||||
));
|
||||
}
|
||||
let display_name = request.display_name.trim();
|
||||
if display_name.is_empty()
|
||||
|| display_name.chars().count() > 80
|
||||
|| display_name.chars().any(char::is_control)
|
||||
@@ -55,47 +183,249 @@ pub(super) async fn start_thread_member(
|
||||
"prompt must contain 1-256000 characters",
|
||||
));
|
||||
}
|
||||
let engine = state
|
||||
Ok((display_name.to_string(), prompt.to_string()))
|
||||
}
|
||||
|
||||
pub(super) async fn start_thread_member(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(thread_id): Path<String>,
|
||||
Json(request): Json<StartMemberRequest>,
|
||||
) -> Result<(StatusCode, Json<MemberReceipt>), ApiError> {
|
||||
let (display_name, prompt) = validate(&request)?;
|
||||
let store_id = state.runtime_threads.job_operation_store_id().to_string();
|
||||
if request
|
||||
.expected_store_id
|
||||
.as_deref()
|
||||
.is_some_and(|expected| expected != store_id)
|
||||
{
|
||||
return Err(ApiError::conflict("member operation store changed")
|
||||
.with_code("member_operation_store_changed"));
|
||||
}
|
||||
let fingerprint = member_request_fingerprint(
|
||||
&thread_id,
|
||||
&request.member_id,
|
||||
&display_name,
|
||||
&prompt,
|
||||
&request.fleet,
|
||||
)
|
||||
.map_err(|_| ApiError::internal("member request fingerprint failed"))?;
|
||||
if request
|
||||
.request_fingerprint
|
||||
.as_deref()
|
||||
.is_some_and(|claimed| claimed != fingerprint)
|
||||
{
|
||||
return Err(
|
||||
ApiError::bad_request("request_fingerprint does not match this request")
|
||||
.with_code("member_request_fingerprint_mismatch"),
|
||||
);
|
||||
}
|
||||
state
|
||||
.runtime_threads
|
||||
.get_engine(&thread_id)
|
||||
.get_thread(&thread_id)
|
||||
.await
|
||||
.map_err(map_thread_err)?;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
engine
|
||||
let generation = state
|
||||
.runtime_threads
|
||||
.job_operation_generation()
|
||||
.map_err(operation_error)?;
|
||||
let store = state.runtime_threads.job_operation_store();
|
||||
let key = request.operation_key.clone();
|
||||
let lookup_thread = thread_id.clone();
|
||||
let existing = {
|
||||
let store = store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
store.with_job_operations(|transaction| {
|
||||
let id = transaction.member_operation_id(&lookup_thread, &key)?;
|
||||
transaction.get_member(&id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("member operation lookup failed"))?
|
||||
.map_err(operation_error)?
|
||||
};
|
||||
if let Some(existing) = existing {
|
||||
if existing.request_fingerprint != fingerprint {
|
||||
return Err(
|
||||
ApiError::conflict("member operation key belongs to a different request")
|
||||
.with_code("member_operation_mismatch"),
|
||||
);
|
||||
}
|
||||
let receipt = replay(&state, existing, generation).await?;
|
||||
if receipt.state == MEMBER_ABANDONED {
|
||||
return Err(ApiError::conflict(
|
||||
"member operation was abandoned before it started; use a new operation_key",
|
||||
)
|
||||
.with_code("member_operation_abandoned"));
|
||||
}
|
||||
return Ok((receipt.status(), Json(receipt)));
|
||||
}
|
||||
|
||||
let reserve_thread = thread_id.clone();
|
||||
let reserve_key = request.operation_key.clone();
|
||||
let reserve_member = request.member_id.clone();
|
||||
let reserve_fingerprint = fingerprint.clone();
|
||||
let reserve_generation = generation.clone();
|
||||
let reserved = {
|
||||
let store = store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
store.with_job_operations(|transaction| {
|
||||
let id = transaction.member_operation_id(&reserve_thread, &reserve_key)?;
|
||||
if let Some(existing) = transaction.get_member(&id)? {
|
||||
return Ok(Err(existing));
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let record = MemberOperationRecord {
|
||||
schema_version: 1,
|
||||
operation: MemberOperation {
|
||||
id,
|
||||
store_id,
|
||||
thread_id: reserve_thread,
|
||||
member_id: reserve_member,
|
||||
operation_key: reserve_key,
|
||||
agent_id: new_member_agent_id(),
|
||||
state: MEMBER_RESERVED.to_string(),
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
},
|
||||
request_fingerprint: reserve_fingerprint,
|
||||
owner_generation: reserve_generation,
|
||||
};
|
||||
transaction.save_member(&record)?;
|
||||
Ok(Ok(record))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("member operation reservation failed"))?
|
||||
.map_err(operation_error)?
|
||||
};
|
||||
let record = match reserved {
|
||||
Ok(record) => record,
|
||||
Err(existing) => {
|
||||
if existing.request_fingerprint != fingerprint {
|
||||
return Err(ApiError::conflict(
|
||||
"member operation key belongs to a different request",
|
||||
)
|
||||
.with_code("member_operation_mismatch"));
|
||||
}
|
||||
let receipt = replay(&state, existing, generation).await?;
|
||||
return Ok((receipt.status(), Json(receipt)));
|
||||
}
|
||||
};
|
||||
|
||||
let engine = match state.runtime_threads.get_engine(&thread_id).await {
|
||||
Ok(engine) => engine,
|
||||
Err(error) => {
|
||||
settle_outcome(state.clone(), &record.operation, Some(Err(String::new()))).await?;
|
||||
return Err(map_thread_err(error));
|
||||
}
|
||||
};
|
||||
let (tx, mut rx) = tokio::sync::oneshot::channel();
|
||||
if let Err(error) = engine
|
||||
.send(codewhale_engine::core::ops::Op::StartFleetMember {
|
||||
start: Box::new(codewhale_engine::tools::subagent::FleetMemberStart {
|
||||
operation_key: request.operation_key.clone(),
|
||||
fleet: request.fleet,
|
||||
member_id: request.member_id.clone(),
|
||||
display_name: display_name.to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
display_name,
|
||||
prompt,
|
||||
reserved_agent_id: Some(record.operation.agent_id.clone()),
|
||||
}),
|
||||
tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| ApiError::internal(format!("Failed to reach the thread's engine: {err}")))?;
|
||||
match tokio::time::timeout(MEMBER_START_WAIT, rx).await {
|
||||
Ok(Ok(Ok(agent_id))) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"thread_id": thread_id,
|
||||
"member_id": request.member_id,
|
||||
"agent_id": agent_id,
|
||||
"state": "started",
|
||||
})),
|
||||
)),
|
||||
Ok(Ok(Err(message))) => Err(ApiError::conflict(message)),
|
||||
{
|
||||
settle_outcome(state.clone(), &record.operation, Some(Err(String::new()))).await?;
|
||||
return Err(ApiError::internal(format!(
|
||||
"Failed to reach the thread's engine: {error}"
|
||||
)));
|
||||
}
|
||||
match tokio::time::timeout(MEMBER_START_WAIT, &mut rx).await {
|
||||
Ok(Ok(Ok(agent_id))) => {
|
||||
let settled = settle_outcome(state.clone(), &record.operation, Some(Ok(agent_id)))
|
||||
.await
|
||||
.is_ok();
|
||||
let receipt = if settled {
|
||||
replay(&state, record.clone(), generation)
|
||||
.await
|
||||
.unwrap_or_else(|_| MemberReceipt::new(&record, MEMBER_UNKNOWN))
|
||||
} else {
|
||||
MemberReceipt::new(&record, MEMBER_UNKNOWN)
|
||||
};
|
||||
Ok((receipt.status(), Json(receipt)))
|
||||
}
|
||||
Ok(Ok(Err(message))) => {
|
||||
settle_outcome(state.clone(), &record.operation, Some(Err(message.clone()))).await?;
|
||||
Err(ApiError::conflict(message))
|
||||
}
|
||||
Ok(Err(_)) => Err(ApiError::internal(
|
||||
"The engine closed before starting the member",
|
||||
)),
|
||||
Err(_) => Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(json!({
|
||||
"thread_id": thread_id,
|
||||
"member_id": request.member_id,
|
||||
"operation_key": request.operation_key,
|
||||
"state": "queued",
|
||||
})),
|
||||
)),
|
||||
Err(_) => {
|
||||
let operation = record.operation.clone();
|
||||
let background = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = rx.await.ok();
|
||||
if let Err(error) = settle_outcome(background, &operation, outcome).await {
|
||||
tracing::warn!(error = %error.message, "member operation settlement failed");
|
||||
}
|
||||
});
|
||||
let receipt = MemberReceipt::new(&record, MEMBER_UNKNOWN);
|
||||
Ok((receipt.status(), Json(receipt)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn replay(
|
||||
state: &RuntimeApiState,
|
||||
record: MemberOperationRecord,
|
||||
generation: String,
|
||||
) -> Result<MemberReceipt, ApiError> {
|
||||
let worker = member_worker(
|
||||
state,
|
||||
&record.operation.thread_id,
|
||||
&record.operation.agent_id,
|
||||
)
|
||||
.await;
|
||||
let store = state.runtime_threads.job_operation_store();
|
||||
let id = record.operation.id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
store.with_job_operations(|transaction| {
|
||||
let mut record = transaction.get_member(&id)?.unwrap_or(record);
|
||||
let state = project(transaction, &mut record, worker.as_ref(), &generation)?;
|
||||
Ok(MemberReceipt::new(&record, &state))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("member operation lookup failed"))?
|
||||
.map_err(operation_error)
|
||||
}
|
||||
|
||||
pub(super) async fn get_member_operation(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((thread_id, key)): Path<(String, String)>,
|
||||
) -> Result<Json<MemberReceipt>, ApiError> {
|
||||
if !valid_member_key(&key) {
|
||||
return Err(ApiError::bad_request("invalid member operation key"));
|
||||
}
|
||||
state
|
||||
.runtime_threads
|
||||
.get_thread(&thread_id)
|
||||
.await
|
||||
.map_err(map_thread_err)?;
|
||||
let generation = state
|
||||
.runtime_threads
|
||||
.job_operation_generation()
|
||||
.map_err(operation_error)?;
|
||||
let store = state.runtime_threads.job_operation_store();
|
||||
let record = tokio::task::spawn_blocking(move || {
|
||||
store.with_job_operations(|transaction| {
|
||||
let id = transaction.member_operation_id(&thread_id, &key)?;
|
||||
transaction.get_member(&id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("member operation lookup failed"))?
|
||||
.map_err(operation_error)?
|
||||
.ok_or_else(|| ApiError::not_found("member operation not found"))?;
|
||||
Ok(Json(replay(&state, record, generation).await?))
|
||||
}
|
||||
@@ -42,6 +42,102 @@ use super::{ApiError, RuntimeApiState};
|
||||
|
||||
const PLUGIN_UI_MAX_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const PLUGIN_UI_SLOT: &str = "customize.plugins.detail";
|
||||
const PLUGIN_UI_SEATS: [&str; 6] = [
|
||||
PLUGIN_UI_SLOT,
|
||||
"conversation.composer.dock",
|
||||
"conversation.chat.node",
|
||||
"workpane.panel",
|
||||
"sidebar.section",
|
||||
"command.row",
|
||||
];
|
||||
const DSH_BOOTSTRAP_ENTRY: &str = "__codewhale_dsh_bootstrap.js";
|
||||
const DSH_FRAME_MODULES: [&str; 4] = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"react/jsx-runtime",
|
||||
];
|
||||
const DSH_ROUTE_METHODS: [&str; 5] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
enum DshSeat {
|
||||
Seat(&'static str),
|
||||
Unsupported(&'static str),
|
||||
}
|
||||
|
||||
const DSH_SEAT_TABLE: [(&str, DshSeat); 30] = [
|
||||
("plugins.bundle.config", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("plugins.row.config", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("plugins.tab", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("plugins.detail.section", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("settings.section", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("settings.plugin.item", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("settings.general.item", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
("settings.plugins.tab", DshSeat::Seat(PLUGIN_UI_SLOT)),
|
||||
(
|
||||
"conversation.chat.node",
|
||||
DshSeat::Seat("conversation.chat.node"),
|
||||
),
|
||||
(
|
||||
"conversation.chat.turnTail",
|
||||
DshSeat::Seat("conversation.chat.node"),
|
||||
),
|
||||
(
|
||||
"conversation.chat.assistant-actions",
|
||||
DshSeat::Seat("conversation.chat.node"),
|
||||
),
|
||||
(
|
||||
"conversation.chat.text-interaction",
|
||||
DshSeat::Seat("conversation.chat.node"),
|
||||
),
|
||||
(
|
||||
"conversation.user.text.node",
|
||||
DshSeat::Seat("conversation.chat.node"),
|
||||
),
|
||||
(
|
||||
"tool.call.toolview",
|
||||
DshSeat::Seat("conversation.chat.node"),
|
||||
),
|
||||
("conversation.view", DshSeat::Seat("conversation.chat.node")),
|
||||
(
|
||||
"conversation.input.dock",
|
||||
DshSeat::Seat("conversation.composer.dock"),
|
||||
),
|
||||
(
|
||||
"conversation.input.overlay",
|
||||
DshSeat::Seat("conversation.composer.dock"),
|
||||
),
|
||||
(
|
||||
"conversation.input.right",
|
||||
DshSeat::Seat("conversation.composer.dock"),
|
||||
),
|
||||
(
|
||||
"conversation.composer.dock",
|
||||
DshSeat::Seat("conversation.composer.dock"),
|
||||
),
|
||||
("sidebar.right.pane.tab", DshSeat::Seat("workpane.panel")),
|
||||
("main.conversation", DshSeat::Seat("workpane.panel")),
|
||||
("main.page-menu", DshSeat::Seat("workpane.panel")),
|
||||
("sidebar.panellist", DshSeat::Seat("sidebar.section")),
|
||||
("sidebar.workspaces", DshSeat::Seat("sidebar.section")),
|
||||
("sidebar.footer.action", DshSeat::Seat("sidebar.section")),
|
||||
(
|
||||
"sidebar.session.row.leading",
|
||||
DshSeat::Seat("sidebar.section"),
|
||||
),
|
||||
(
|
||||
"sidebar.workspaces.session.row.action",
|
||||
DshSeat::Seat("sidebar.section"),
|
||||
),
|
||||
(
|
||||
"conversation.session.header.actions",
|
||||
DshSeat::Unsupported("no ADE seat yet"),
|
||||
),
|
||||
(
|
||||
"conversation.session.header.utilities",
|
||||
DshSeat::Unsupported("no ADE seat yet"),
|
||||
),
|
||||
("contextMenu.icon", DshSeat::Unsupported("no ADE seat yet")),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response shapes
|
||||
@@ -176,9 +272,20 @@ pub(super) struct PluginActionResponse {
|
||||
pub(super) note: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct PluginUiBridge {
|
||||
pub(super) kind: &'static str,
|
||||
pub(super) dsh_version: String,
|
||||
pub(super) compatibility_digest: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct PluginUiSlotResponse {
|
||||
pub(super) slot: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) dsh_slot: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) bridge: Option<PluginUiBridge>,
|
||||
pub(super) base: String,
|
||||
pub(super) entry: String,
|
||||
pub(super) revision: String,
|
||||
@@ -209,6 +316,7 @@ pub(super) struct InstallPluginRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PluginUiQuery {
|
||||
pub(super) slot: Option<String>,
|
||||
pub(super) dsh_slot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -519,6 +627,18 @@ struct PluginUiGrant {
|
||||
ui_prefix: PathBuf,
|
||||
file_hashes: Arc<BTreeMap<PathBuf, String>>,
|
||||
expires: std::time::Instant,
|
||||
dsh: Option<Arc<DshServed>>,
|
||||
}
|
||||
|
||||
struct DshClosure {
|
||||
package: String,
|
||||
entry: String,
|
||||
files: BTreeMap<String, String>,
|
||||
host_provided: Vec<String>,
|
||||
}
|
||||
|
||||
struct DshServed {
|
||||
closure: DshClosure,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -548,6 +668,12 @@ impl PluginUiGrants {
|
||||
}
|
||||
}
|
||||
|
||||
fn revoke_selector(&self, selector: &str) {
|
||||
self.entries
|
||||
.lock()
|
||||
.retain(|_, value| value.selector != selector);
|
||||
}
|
||||
|
||||
fn get(&self, revision: &str) -> Option<PluginUiGrant> {
|
||||
let mut entries = self.entries.lock();
|
||||
let now = std::time::Instant::now();
|
||||
@@ -561,7 +687,8 @@ fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) ->
|
||||
UiFileError::Missing => ApiError::not_found(format!(
|
||||
"plugin '{}' has no staged file '{display}'",
|
||||
plugin.name()
|
||||
)),
|
||||
))
|
||||
.with_code("plugin_ui_file_not_reviewed"),
|
||||
UiFileError::TooLarge => ApiError::payload_too_large(format!(
|
||||
"plugin '{}' file '{display}' exceeds the 8 MiB plugin UI limit",
|
||||
plugin.name()
|
||||
@@ -656,6 +783,15 @@ fn admit_ui_grant(state: &RuntimeApiState, grant: &PluginUiGrant) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_dsh_slot_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
key.len() <= 128
|
||||
&& chars
|
||||
.next()
|
||||
.is_some_and(|first| first.is_ascii_alphanumeric())
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
}
|
||||
|
||||
fn ui_stale_revision() -> ApiError {
|
||||
ApiError {
|
||||
status: StatusCode::PRECONDITION_FAILED,
|
||||
@@ -664,10 +800,300 @@ fn ui_stale_revision() -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedPluginUi {
|
||||
entry: String,
|
||||
dsh_slot: Option<String>,
|
||||
bridge: Option<PluginUiBridge>,
|
||||
grant: PluginUiGrant,
|
||||
}
|
||||
|
||||
fn seat_not_admitted(plugin: &LoadedPlugin, seat: &str, dsh_slot: Option<&str>) -> ApiError {
|
||||
let key = dsh_slot
|
||||
.map(|key| format!(" key '{key}'"))
|
||||
.unwrap_or_default();
|
||||
ApiError::not_found(format!(
|
||||
"plugin '{}' has no admitted client for seat '{seat}'{key}",
|
||||
plugin.name()
|
||||
))
|
||||
.with_code("plugin_ui_seat_not_admitted")
|
||||
}
|
||||
|
||||
fn ui_unsupported(message: impl Into<String>) -> ApiError {
|
||||
ApiError::bad_request(message).with_code("plugin_ui_unsupported")
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dsh_client_closure(conversion: &Value) -> Option<DshClosure> {
|
||||
let closure = conversion
|
||||
.get("client_closures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|closure| closure.get("root").and_then(Value::as_str) == Some(""))?;
|
||||
let files = closure
|
||||
.get("files")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.map(|(path, hash)| Some((path.clone(), hash.as_str()?.to_string())))
|
||||
.collect::<Option<BTreeMap<_, _>>>()?;
|
||||
let entry = closure.get("entry")?.as_str()?.to_string();
|
||||
if !files.contains_key(&entry) {
|
||||
return None;
|
||||
}
|
||||
Some(DshClosure {
|
||||
package: closure
|
||||
.get("package")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
entry,
|
||||
files,
|
||||
host_provided: closure
|
||||
.get("host_provided")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_dsh_closure(
|
||||
grant: &PluginUiGrant,
|
||||
closure: &DshClosure,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, UiFileError> {
|
||||
let mut verified = Vec::with_capacity(closure.files.len());
|
||||
for (path, expected) in &closure.files {
|
||||
let relative = ui_relative_path(path)
|
||||
.map_err(|reason| UiFileError::Invalid(format!("closure file '{path}': {reason}")))?;
|
||||
let bytes = verified_ui_file(grant, &relative)?;
|
||||
if sha256_hex(&bytes) != *expected {
|
||||
return Err(UiFileError::Invalid(format!(
|
||||
"closure file '{path}' no longer matches its reviewed hash"
|
||||
)));
|
||||
}
|
||||
verified.push((path.clone(), bytes));
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
fn has_esm_syntax(text: &str) -> bool {
|
||||
text.contains("import.meta")
|
||||
|| text.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
[
|
||||
"import ", "import{", "import\"", "import'", "export ", "export{", "export*",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| line.starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn dsh_closure_unsupported(closure: &DshClosure, verified: &[(String, Vec<u8>)]) -> Option<String> {
|
||||
let missing: Vec<&str> = closure
|
||||
.host_provided
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|module| !DSH_FRAME_MODULES.contains(module))
|
||||
.take(5)
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Some(format!(
|
||||
"this DSH client requires modules the plugin frame does not provide yet: {}",
|
||||
missing.join(", ")
|
||||
));
|
||||
}
|
||||
for (path, bytes) in verified {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Some(format!("closure file '{path}' is not UTF-8 text"));
|
||||
};
|
||||
if path.ends_with(".json") {
|
||||
if serde_json::from_str::<Value>(text).is_err() {
|
||||
return Some(format!("closure file '{path}' is not valid JSON"));
|
||||
}
|
||||
} else if has_esm_syntax(text) {
|
||||
return Some(format!(
|
||||
"closure file '{path}' uses ES module syntax; only CommonJS closures are served"
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn quoted_in(text: &str, key: &str) -> bool {
|
||||
['"', '\'', '`']
|
||||
.iter()
|
||||
.any(|quote| text.contains(&format!("{quote}{key}{quote}")))
|
||||
}
|
||||
|
||||
fn declared_dsh_keys(verified: &[(String, Vec<u8>)]) -> Vec<&'static (&'static str, DshSeat)> {
|
||||
let texts: Vec<String> = verified
|
||||
.iter()
|
||||
.map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
|
||||
.collect();
|
||||
DSH_SEAT_TABLE
|
||||
.iter()
|
||||
.filter(|(key, _)| texts.iter().any(|text| quoted_in(text, key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn admit_dsh_seat(
|
||||
plugin: &LoadedPlugin,
|
||||
seat: &str,
|
||||
requested: Option<&str>,
|
||||
declared: &[&'static (&'static str, DshSeat)],
|
||||
) -> Result<String, ApiError> {
|
||||
if let Some(key) = requested {
|
||||
let Some((_, mapped)) = declared.iter().find(|(name, _)| *name == key) else {
|
||||
return Err(seat_not_admitted(plugin, seat, requested));
|
||||
};
|
||||
return match mapped {
|
||||
DshSeat::Unsupported(reason) => Err(ApiError {
|
||||
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
message: format!(
|
||||
"plugin '{}' declares '{key}', which has no Codewhale seat: {reason}",
|
||||
plugin.name()
|
||||
),
|
||||
code: Some("plugin_ui_seat_unsupported"),
|
||||
}),
|
||||
DshSeat::Seat(mapped_seat) if *mapped_seat == seat => Ok(key.to_string()),
|
||||
DshSeat::Seat(_) => Err(seat_not_admitted(plugin, seat, requested)),
|
||||
};
|
||||
}
|
||||
let mut on_seat = declared.iter().filter_map(|(name, mapped)| match mapped {
|
||||
DshSeat::Seat(mapped_seat) if *mapped_seat == seat => Some(*name),
|
||||
_ => None,
|
||||
});
|
||||
match (on_seat.next(), on_seat.next()) {
|
||||
(None, _) => Err(seat_not_admitted(plugin, seat, None)),
|
||||
(Some(key), None) => Ok(key.to_string()),
|
||||
(Some(_), Some(_)) => Err(ApiError::bad_request(format!(
|
||||
"plugin '{}' admits more than one client key on seat '{seat}'; pass dsh_slot",
|
||||
plugin.name()
|
||||
))
|
||||
.with_code("plugin_ui_dsh_slot_required")),
|
||||
}
|
||||
}
|
||||
|
||||
fn dsh_bridge() -> Result<PluginUiBridge, ApiError> {
|
||||
let receipt: Value = serde_json::from_slice(codewhale_plugins::activation::DSH_COMPATIBILITY)
|
||||
.map_err(|_| {
|
||||
ApiError::internal("the pinned DSH compatibility receipt is unreadable")
|
||||
})?;
|
||||
let version = receipt
|
||||
.get("target_dsh_version")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| ApiError::internal("the pinned DSH compatibility receipt has no version"))?;
|
||||
Ok(PluginUiBridge {
|
||||
kind: "dsh-client",
|
||||
dsh_version: version.to_string(),
|
||||
compatibility_digest: sha256_hex(codewhale_plugins::activation::DSH_COMPATIBILITY),
|
||||
})
|
||||
}
|
||||
|
||||
const DSH_BOOTSTRAP_HEAD: &str = r#""use strict";
|
||||
(function () {
|
||||
var files = {
|
||||
"#;
|
||||
|
||||
const DSH_BOOTSTRAP_TAIL: &str = r#" var cache = {};
|
||||
var probes = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
|
||||
var hostRequire = null;
|
||||
function normalize(path) {
|
||||
var out = [];
|
||||
path.split("/").forEach(function (part) {
|
||||
if (part === "" || part === ".") return;
|
||||
if (part === "..") out.pop();
|
||||
else out.push(part);
|
||||
});
|
||||
return out.join("/");
|
||||
}
|
||||
function resolve(from, id) {
|
||||
var base = normalize(from.slice(0, from.lastIndexOf("/")) + "/" + id);
|
||||
for (var i = 0; i < probes.length; i++) {
|
||||
if (Object.prototype.hasOwnProperty.call(files, base + probes[i])) return base + probes[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function load(path) {
|
||||
if (Object.prototype.hasOwnProperty.call(cache, path)) return cache[path].exports;
|
||||
var module = { exports: {} };
|
||||
cache[path] = module;
|
||||
files[path].call(module.exports, module, module.exports, makeRequire(path));
|
||||
return module.exports;
|
||||
}
|
||||
function makeRequire(from) {
|
||||
return function (id) {
|
||||
if (id.charAt(0) === ".") {
|
||||
var resolved = resolve(from, id);
|
||||
if (resolved === null) throw new Error("the plugin client requires a file outside its reviewed closure: " + id);
|
||||
return load(resolved);
|
||||
}
|
||||
return hostRequire(id);
|
||||
};
|
||||
}
|
||||
window.__ModuleLoader__.load({
|
||||
id: PLUGIN_ID,
|
||||
factory: function (require) {
|
||||
hostRequire = require;
|
||||
return load(ENTRY);
|
||||
}
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
fn dsh_bootstrap(
|
||||
selector: &str,
|
||||
closure: &DshClosure,
|
||||
verified: &[(String, Vec<u8>)],
|
||||
) -> Result<Vec<u8>, UiFileError> {
|
||||
let quote = |text: &str| {
|
||||
serde_json::to_string(text).map_err(|error| UiFileError::Internal(error.to_string()))
|
||||
};
|
||||
let mut out = String::from(DSH_BOOTSTRAP_HEAD);
|
||||
for (path, bytes) in verified {
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|_| UiFileError::Invalid(format!("closure file '{path}' is not UTF-8")))?;
|
||||
out.push_str(" ");
|
||||
out.push_str("e(path)?);
|
||||
out.push_str(": function (module, exports, require) {\n");
|
||||
if path.ends_with(".json") {
|
||||
out.push_str("module.exports = ");
|
||||
out.push_str(text);
|
||||
out.push(';');
|
||||
} else {
|
||||
out.push_str(text);
|
||||
}
|
||||
out.push_str("\n},\n");
|
||||
}
|
||||
out.push_str(" };\n");
|
||||
out.push_str(&format!(
|
||||
" var PLUGIN_ID = {};\n var ENTRY = {};\n",
|
||||
quote(if closure.package.is_empty() {
|
||||
selector
|
||||
} else {
|
||||
&closure.package
|
||||
})?,
|
||||
quote(&closure.entry)?
|
||||
));
|
||||
out.push_str(DSH_BOOTSTRAP_TAIL);
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
fn prepare_plugin_ui(
|
||||
state: &RuntimeApiState,
|
||||
selector: &str,
|
||||
) -> Result<(String, PluginUiGrant), ApiError> {
|
||||
seat: &str,
|
||||
dsh_slot: Option<&str>,
|
||||
) -> Result<PreparedPluginUi, ApiError> {
|
||||
let registry = registry_for_state(state);
|
||||
let plugin = registry
|
||||
.get(selector)
|
||||
@@ -718,6 +1144,7 @@ fn prepare_plugin_ui(
|
||||
ui_prefix: PathBuf::new(),
|
||||
file_hashes: Arc::new(validated.file_hashes),
|
||||
expires: std::time::Instant::now() + std::time::Duration::from_secs(3600),
|
||||
dsh: None,
|
||||
};
|
||||
let mut found = None;
|
||||
for candidate in ["package.json", "source/package.json"] {
|
||||
@@ -745,6 +1172,7 @@ fn prepare_plugin_ui(
|
||||
return Err(ApiError::not_found(format!("plugin '{}' declares no dsh.client UI in {}; add dsh.client and exports[\"./client\"] to expose client assets", plugin.name(), ui_relative_display(&manifest_path))).with_code("plugin_ui_not_declared"));
|
||||
}
|
||||
let conversion_path = FsPath::new("CONVERSION.json");
|
||||
let mut closure = None;
|
||||
if grant.file_hashes.contains_key(conversion_path) {
|
||||
let bytes = verified_ui_file(&grant, conversion_path)
|
||||
.map_err(|error| ui_file_refusal(&plugin, "CONVERSION.json", error))?;
|
||||
@@ -759,16 +1187,39 @@ fn prepare_plugin_ui(
|
||||
.and_then(Value::as_str)
|
||||
== Some("dsh")
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"this DSH client UI was not converted and is not supported by Codewhale yet",
|
||||
)
|
||||
.with_code("plugin_ui_unsupported"));
|
||||
closure = Some(dsh_client_closure(&conversion).ok_or_else(|| {
|
||||
ui_unsupported(
|
||||
"this DSH client UI was not converted and is not supported by Codewhale yet",
|
||||
)
|
||||
})?);
|
||||
}
|
||||
}
|
||||
grant.ui_prefix = manifest_path
|
||||
.parent()
|
||||
.unwrap_or(FsPath::new(""))
|
||||
.to_path_buf();
|
||||
if let Some(closure) = closure {
|
||||
let verified = verify_dsh_closure(&grant, &closure)
|
||||
.map_err(|error| ui_file_refusal(&plugin, "client closure", error))?;
|
||||
if let Some(reason) = dsh_closure_unsupported(&closure, &verified) {
|
||||
return Err(ui_unsupported(format!(
|
||||
"plugin '{}': {reason}",
|
||||
plugin.name()
|
||||
)));
|
||||
}
|
||||
let key = admit_dsh_seat(&plugin, seat, dsh_slot, &declared_dsh_keys(&verified))?;
|
||||
grant.dsh = Some(Arc::new(DshServed { closure }));
|
||||
admit_ui_grant(state, &grant)?;
|
||||
return Ok(PreparedPluginUi {
|
||||
entry: DSH_BOOTSTRAP_ENTRY.to_string(),
|
||||
dsh_slot: Some(key),
|
||||
bridge: Some(dsh_bridge()?),
|
||||
grant,
|
||||
});
|
||||
}
|
||||
if seat != PLUGIN_UI_SLOT || dsh_slot.is_some() {
|
||||
return Err(seat_not_admitted(&plugin, seat, dsh_slot));
|
||||
}
|
||||
let raw = ui_client_entry(&manifest).unwrap_or_else(|| "client.js".into());
|
||||
let relative = ui_relative_path(&raw).map_err(|reason| {
|
||||
ApiError::bad_request(format!(
|
||||
@@ -779,7 +1230,12 @@ fn prepare_plugin_ui(
|
||||
verified_ui_file(&grant, &grant.ui_prefix.join(&relative))
|
||||
.map_err(|error| ui_file_refusal(&plugin, &ui_relative_display(&relative), error))?;
|
||||
admit_ui_grant(state, &grant)?;
|
||||
Ok((ui_relative_display(&relative), grant))
|
||||
Ok(PreparedPluginUi {
|
||||
entry: ui_relative_display(&relative),
|
||||
dsh_slot: None,
|
||||
bridge: None,
|
||||
grant,
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_ui_base(
|
||||
@@ -909,6 +1365,8 @@ async fn run_plugin_mutation(
|
||||
PluginMutationOutcome::Uninstalled => "uninstalled",
|
||||
};
|
||||
|
||||
state.plugin_ui_grants.revoke_selector(&receipt.name);
|
||||
|
||||
// Mutations can change merged plugin MCP servers. Advance the existing
|
||||
// shared generation so each captured workspace pool reloads on next use.
|
||||
state
|
||||
@@ -984,6 +1442,8 @@ async fn run_registry_mutation(
|
||||
ApiError::conflict(format!("{action} failed for '{selector}': {error}"))
|
||||
})?;
|
||||
|
||||
state.plugin_ui_grants.revoke_selector(selector);
|
||||
|
||||
state
|
||||
.workspace_scopes
|
||||
.mcp_generation
|
||||
@@ -1265,43 +1725,58 @@ pub(super) async fn get_plugin_ui(
|
||||
));
|
||||
}
|
||||
let slot = query.slot.ok_or_else(|| {
|
||||
ApiError::bad_request(
|
||||
"the 'slot' query parameter is required; supported slot: 'customize.plugins.detail'",
|
||||
)
|
||||
ApiError::bad_request(format!(
|
||||
"the 'slot' query parameter is required; seats: {}",
|
||||
PLUGIN_UI_SEATS.join(", ")
|
||||
))
|
||||
.with_code("plugin_ui_seat_unknown")
|
||||
})?;
|
||||
if slot.as_str() != PLUGIN_UI_SLOT {
|
||||
if !PLUGIN_UI_SEATS.contains(&slot.as_str()) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"slot '{slot}' is not supported; supported slot: 'customize.plugins.detail'"
|
||||
)));
|
||||
"slot '{slot}' is not a plugin UI seat; seats: {}",
|
||||
PLUGIN_UI_SEATS.join(", ")
|
||||
))
|
||||
.with_code("plugin_ui_seat_unknown"));
|
||||
}
|
||||
if let Some(key) = &query.dsh_slot
|
||||
&& !valid_dsh_slot_key(key)
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"dsh_slot must be 1 to 128 characters of letters, digits, '.', '_' or '-', starting with a letter or digit",
|
||||
)
|
||||
.with_code("plugin_ui_dsh_slot_invalid"));
|
||||
}
|
||||
let state_for_prepare = state.clone();
|
||||
let selector_for_prepare = selector.clone();
|
||||
let (entry, grant) = tokio::task::spawn_blocking(move || {
|
||||
prepare_plugin_ui(&state_for_prepare, &selector_for_prepare)
|
||||
let seat = slot.clone();
|
||||
let requested_key = query.dsh_slot.clone();
|
||||
let prepared = tokio::task::spawn_blocking(move || {
|
||||
prepare_plugin_ui(
|
||||
&state_for_prepare,
|
||||
&selector_for_prepare,
|
||||
&seat,
|
||||
requested_key.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("plugin UI preparation failed"))??;
|
||||
let base = plugin_ui_base(&state, &selector, &headers, &uri)?;
|
||||
let revision = state.plugin_ui_grants.insert(grant);
|
||||
let revision = state.plugin_ui_grants.insert(prepared.grant);
|
||||
Ok(Json(PluginUiSlotResponse {
|
||||
slot,
|
||||
dsh_slot: prepared.dsh_slot,
|
||||
bridge: prepared.bridge,
|
||||
base,
|
||||
entry,
|
||||
entry: prepared.entry,
|
||||
revision,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn get_plugin_ui_asset(
|
||||
State(state): State<RuntimeApiState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Path((selector, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !peer.ip().is_loopback() {
|
||||
return Err(ApiError::forbidden(
|
||||
"plugin UI assets are available only from loopback",
|
||||
));
|
||||
}
|
||||
fn grant_for_request(
|
||||
state: &RuntimeApiState,
|
||||
selector: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(PluginUiGrant, String), ApiError> {
|
||||
let mut values = headers.get_all(header::IF_MATCH).iter();
|
||||
let first = values.next().ok_or_else(|| ApiError {
|
||||
status: StatusCode::PRECONDITION_REQUIRED,
|
||||
@@ -1319,25 +1794,101 @@ pub(super) async fn get_plugin_ui_asset(
|
||||
if grant.selector != selector || grant.authority.workspace != state.workspace {
|
||||
return Err(ui_stale_revision());
|
||||
}
|
||||
Ok((grant, revision))
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_route_bridge(
|
||||
State(state): State<RuntimeApiState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Path((selector, path)): Path<(String, String)>,
|
||||
method: axum::http::Method,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !peer.ip().is_loopback() {
|
||||
return Err(ApiError::forbidden(
|
||||
"plugin routes are available only from loopback",
|
||||
));
|
||||
}
|
||||
let (grant, revision) = grant_for_request(&state, &selector, &headers)?;
|
||||
let state_for_route = state.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
|
||||
.map_err(|_| ui_stale_revision())?;
|
||||
admit_ui_grant(&state_for_route, &grant)?;
|
||||
if state_for_route.plugin_ui_grants.get(&revision).is_none() {
|
||||
return Err(ui_stale_revision());
|
||||
}
|
||||
if grant.dsh.is_none() {
|
||||
return Err(ApiError::forbidden(
|
||||
"this plugin UI grant has no DSH client and no plugin routes",
|
||||
)
|
||||
.with_code("plugin_route_not_admitted"));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("plugin route check failed"))??;
|
||||
ui_relative_path(&path).map_err(|reason| {
|
||||
ApiError::bad_request(format!("plugin route path '{path}' is invalid: {reason}"))
|
||||
})?;
|
||||
if !DSH_ROUTE_METHODS.contains(&method.as_str()) {
|
||||
return Err(ApiError {
|
||||
status: StatusCode::METHOD_NOT_ALLOWED,
|
||||
message: format!("plugin routes do not accept {method}"),
|
||||
code: None,
|
||||
});
|
||||
}
|
||||
Err(ApiError::not_implemented(
|
||||
"this Engine has no plugin web server to forward the route to yet",
|
||||
)
|
||||
.with_code("plugin_route_not_served"))
|
||||
}
|
||||
|
||||
pub(super) async fn get_plugin_ui_asset(
|
||||
State(state): State<RuntimeApiState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Path((selector, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !peer.ip().is_loopback() {
|
||||
return Err(ApiError::forbidden(
|
||||
"plugin UI assets are available only from loopback",
|
||||
));
|
||||
}
|
||||
let (grant, revision) = grant_for_request(&state, &selector, &headers)?;
|
||||
let relative = ui_relative_path(&path).map_err(|reason| {
|
||||
ApiError::bad_request(format!("plugin UI path '{path}' is invalid: {reason}"))
|
||||
})?;
|
||||
let content_type = ui_content_type(&relative);
|
||||
if grant.dsh.is_some() && relative != FsPath::new(DSH_BOOTSTRAP_ENTRY) {
|
||||
return Err(
|
||||
ApiError::not_found("plugin UI file is not in the reviewed client closure")
|
||||
.with_code("plugin_ui_file_not_reviewed"),
|
||||
);
|
||||
}
|
||||
let state_for_asset = state.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
|
||||
.map_err(|_| ui_stale_revision())?;
|
||||
let bytes = verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|
||||
|error| match error {
|
||||
UiFileError::Missing => {
|
||||
ApiError::not_found("plugin UI file is not in the reviewed bundle")
|
||||
}
|
||||
UiFileError::TooLarge => {
|
||||
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
|
||||
}
|
||||
_ => ui_stale_revision(),
|
||||
},
|
||||
)?;
|
||||
let bytes = if let Some(dsh) = &grant.dsh {
|
||||
let verified =
|
||||
verify_dsh_closure(&grant, &dsh.closure).map_err(|_| ui_stale_revision())?;
|
||||
dsh_bootstrap(&grant.selector, &dsh.closure, &verified)
|
||||
.map_err(|_| ui_stale_revision())?
|
||||
} else {
|
||||
verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|
||||
|error| match error {
|
||||
UiFileError::Missing => {
|
||||
ApiError::not_found("plugin UI file is not in the reviewed bundle")
|
||||
.with_code("plugin_ui_file_not_reviewed")
|
||||
}
|
||||
UiFileError::TooLarge => {
|
||||
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
|
||||
}
|
||||
_ => ui_stale_revision(),
|
||||
},
|
||||
)?
|
||||
};
|
||||
admit_ui_grant(&state_for_asset, &grant)?;
|
||||
if state_for_asset.plugin_ui_grants.get(&revision).is_none() {
|
||||
return Err(ui_stale_revision());
|
||||
|
||||
@@ -18,9 +18,16 @@ use uuid::Uuid;
|
||||
mod command_catalog;
|
||||
#[cfg(unix)]
|
||||
mod control_socket_selection;
|
||||
#[cfg(unix)]
|
||||
mod external_agent_options;
|
||||
#[cfg(unix)]
|
||||
mod external_agent_quota;
|
||||
mod headless_catalog;
|
||||
#[cfg(any(unix, windows))]
|
||||
mod member_operations;
|
||||
#[cfg(unix)]
|
||||
mod owner_bootstrap;
|
||||
mod plugin_ui;
|
||||
mod profile_constitution;
|
||||
#[cfg(any(unix, windows))]
|
||||
mod runtime_store_convergence;
|
||||
@@ -8192,18 +8199,13 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
|
||||
);
|
||||
let snapshot_count = repo.list(usize::MAX)?.len();
|
||||
let (preview, paths) =
|
||||
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, true, None)
|
||||
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, None)
|
||||
.expect("a trusted preview should succeed");
|
||||
assert!(!preview.files_restored);
|
||||
assert_eq!(paths, vec!["a.txt", "made/new.txt"]);
|
||||
let refused = patch_undo_workspace_files_impl(
|
||||
&workspace,
|
||||
std::slice::from_ref(&turn),
|
||||
true,
|
||||
false,
|
||||
Some(&[]),
|
||||
)
|
||||
.expect_err("an unreviewed file plan must refuse before restoring");
|
||||
let refused =
|
||||
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), false, Some(&[]))
|
||||
.expect_err("an unreviewed file plan must refuse before restoring");
|
||||
assert_eq!(refused.status, StatusCode::CONFLICT);
|
||||
assert_eq!(refused.code, Some("undo_selection_changed"));
|
||||
|
||||
@@ -8212,7 +8214,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
|
||||
assert_eq!(fs::read_to_string(&other)?, "later work");
|
||||
assert_eq!(repo.list(usize::MAX)?.len(), snapshot_count);
|
||||
|
||||
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
|
||||
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
|
||||
.expect("a trusted rollback should succeed");
|
||||
assert!(restored.files_restored, "{:?}", restored.summary);
|
||||
assert_eq!(fs::read_to_string(&a)?, "before", "both writes are undone");
|
||||
@@ -8232,7 +8234,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
|
||||
);
|
||||
|
||||
// Undoing again finds the files already at their pre-turn state.
|
||||
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
|
||||
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
|
||||
.expect("already restored is provably nothing to do");
|
||||
assert!(!again.files_restored);
|
||||
Ok(())
|
||||
@@ -8266,7 +8268,7 @@ fn patch_undo_helper_refuses_to_overwrite_later_edits() -> Result<()> {
|
||||
true,
|
||||
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
|
||||
);
|
||||
let err = patch_undo_workspace_files(&workspace, &[turn], true)
|
||||
let err = patch_undo_workspace_files(&workspace, &[turn])
|
||||
.expect_err("a later edit must not be overwritten");
|
||||
assert_eq!(err.status, StatusCode::CONFLICT);
|
||||
assert_eq!(err.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
|
||||
@@ -8322,7 +8324,7 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
|
||||
),
|
||||
];
|
||||
for (turn, code) in cases {
|
||||
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
|
||||
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
|
||||
.expect_err(&turn.turn_id);
|
||||
assert_eq!(err.status, StatusCode::CONFLICT, "{}", turn.turn_id);
|
||||
assert_eq!(err.code, Some(code), "{}: {}", turn.turn_id, err.message);
|
||||
@@ -8330,20 +8332,16 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
|
||||
assert_eq!(fs::read_to_string(&a)?, "after");
|
||||
}
|
||||
|
||||
let chat_only = patch_undo_workspace_files(
|
||||
&workspace,
|
||||
&[dropped_turn("chat", false, Vec::new())],
|
||||
false,
|
||||
)
|
||||
.expect("a turn that ran no tools changed no files");
|
||||
let chat_only =
|
||||
patch_undo_workspace_files(&workspace, &[dropped_turn("chat", false, Vec::new())])
|
||||
.expect("a turn that ran no tools changed no files");
|
||||
assert!(!chat_only.files_restored);
|
||||
|
||||
// A prune that drops the restore point's tree fails closed too.
|
||||
let receipts = shell_turn_receipts(&repo, &pre, &post, "thr_a");
|
||||
repo.prune_older_than(Duration::ZERO)?;
|
||||
let err =
|
||||
patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)], true)
|
||||
.expect_err("a pruned restore point cannot be restored");
|
||||
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)])
|
||||
.expect_err("a pruned restore point cannot be restored");
|
||||
assert_eq!(err.code, Some(PATCH_UNDO_RESTORE_POINT_PRUNED));
|
||||
assert_eq!(fs::read_to_string(&a)?, "after");
|
||||
Ok(())
|
||||
@@ -8387,7 +8385,6 @@ fn patch_undo_helper_survives_a_prune_that_rewrites_commit_ids() -> Result<()> {
|
||||
true,
|
||||
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
|
||||
)],
|
||||
true,
|
||||
)
|
||||
.expect("the tree still resolves the restore point");
|
||||
assert!(restored.files_restored);
|
||||
@@ -8481,7 +8478,6 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
|
||||
let err = patch_undo_workspace_files(
|
||||
&workspace,
|
||||
&[dropped_turn("turn_1", true, receipts(gap_writer))],
|
||||
true,
|
||||
)
|
||||
.expect_err("another writer's change must not be reverted");
|
||||
assert_eq!(err.status, StatusCode::CONFLICT);
|
||||
@@ -8508,12 +8504,9 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
|
||||
None,
|
||||
Some(changed_between(&repo, &post_tool, &clean_post)),
|
||||
);
|
||||
let restored = patch_undo_workspace_files(
|
||||
&workspace,
|
||||
&[dropped_turn("turn_1", true, clean.clone())],
|
||||
true,
|
||||
)
|
||||
.expect("the turn's own write restores");
|
||||
let restored =
|
||||
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, clean.clone())])
|
||||
.expect("the turn's own write restores");
|
||||
assert!(restored.files_restored, "{:?}", restored.summary);
|
||||
assert_eq!(fs::read_to_string(&own)?, "before");
|
||||
|
||||
@@ -8521,9 +8514,8 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
|
||||
fs::write(&own, "turn")?;
|
||||
let mut shell = clean.clone();
|
||||
shell[1].write_paths = None;
|
||||
let restored =
|
||||
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)], true)
|
||||
.expect("an undeclared span owns its changes");
|
||||
let restored = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)])
|
||||
.expect("an undeclared span owns its changes");
|
||||
assert!(restored.files_restored);
|
||||
assert_eq!(fs::read_to_string(&own)?, "before");
|
||||
|
||||
@@ -8531,9 +8523,8 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
|
||||
fs::write(&own, "turn")?;
|
||||
let mut unknown = clean;
|
||||
unknown[2].changed_paths = None;
|
||||
let err =
|
||||
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)], true)
|
||||
.expect_err("an unaccounted span fails closed");
|
||||
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)])
|
||||
.expect_err("an unaccounted span fails closed");
|
||||
assert_eq!(
|
||||
err.code,
|
||||
Some(PATCH_UNDO_NO_RESTORE_POINT),
|
||||
@@ -8594,7 +8585,6 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
|
||||
let err = patch_undo_workspace_files(
|
||||
&workspace,
|
||||
&[dropped_turn("turn_1", true, receipts(&[declared]))],
|
||||
true,
|
||||
)
|
||||
.expect_err(declared);
|
||||
assert_eq!(err.status, StatusCode::CONFLICT, "{declared}");
|
||||
@@ -8609,24 +8599,24 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
|
||||
// The same declared write from a call recorded as failed wrote nothing.
|
||||
let mut failed = dropped_turn("turn_1", true, receipts(&[".env.local"]));
|
||||
failed.unrun_tool_calls.insert("call-1".to_string());
|
||||
let result = patch_undo_workspace_files(&workspace, &[failed], true)
|
||||
.expect("a failed call wrote nothing");
|
||||
let result =
|
||||
patch_undo_workspace_files(&workspace, &[failed]).expect("a failed call wrote nothing");
|
||||
assert!(!result.files_restored);
|
||||
// Declared through the turn's items rather than a receipt.
|
||||
let mut from_items = dropped_turn("turn_1", true, receipts(&[]));
|
||||
from_items.declared_writes.push(".env.local".to_string());
|
||||
let err = patch_undo_workspace_files(&workspace, &[from_items], true)
|
||||
let err = patch_undo_workspace_files(&workspace, &[from_items])
|
||||
.expect_err("an item-declared ignored write refuses too");
|
||||
assert_eq!(err.code, Some(PATCH_UNDO_PATH_NOT_SNAPSHOTTED));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The gate the TUI's `/undo` applies, stated as a contract: an untrusted
|
||||
/// thread may not roll files back. When there *is* something to roll back the
|
||||
/// whole undo aborts — nothing is changed, and the caller must not fork the
|
||||
/// conversation either.
|
||||
/// Default posture (no trust mode, no Full Access): the Runtime does not
|
||||
/// gate on trust at all, because the ownership and later-edit proofs already
|
||||
/// decide what may be reverted. A later edit in the same workspace still
|
||||
/// refuses and leaves every byte alone.
|
||||
#[test]
|
||||
fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Result<()> {
|
||||
fn patch_undo_helper_restores_in_the_default_posture_and_still_refuses_later_edits() -> Result<()> {
|
||||
if git_missing() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -8651,69 +8641,20 @@ fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Resu
|
||||
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
|
||||
);
|
||||
|
||||
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), false)
|
||||
.expect_err("an untrusted rollback must be refused");
|
||||
assert_eq!(err.status, StatusCode::CONFLICT);
|
||||
assert_eq!(err.code, Some(PATCH_UNDO_UNTRUSTED));
|
||||
assert!(
|
||||
err.message.contains("outside trusted mode"),
|
||||
"got: {}",
|
||||
err.message
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&file)?,
|
||||
"after",
|
||||
"a refusal must leave the workspace exactly as it was"
|
||||
);
|
||||
fs::write(&file, "edited later by the person")?;
|
||||
let refused = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
|
||||
.expect_err("a later edit must not be overwritten");
|
||||
assert_eq!(refused.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
|
||||
assert_eq!(fs::read_to_string(&file)?, "edited later by the person");
|
||||
|
||||
// The identical call once trusted performs the rollback.
|
||||
let restored = patch_undo_workspace_files(&workspace, &[turn], true)
|
||||
.expect("a trusted rollback should succeed");
|
||||
fs::write(&file, "after")?;
|
||||
let restored = patch_undo_workspace_files(&workspace, &[turn])
|
||||
.expect("the turn's own change restores without trust");
|
||||
assert!(restored.files_restored, "{:?}", restored.summary);
|
||||
assert_eq!(fs::read_to_string(&file)?, "before");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ...but with provably nothing to roll back, an untrusted undo is still
|
||||
/// allowed: the conversation half needs no trust, and skipping it would make
|
||||
/// Undo useless in Ask / Auto-Review for no safety gain.
|
||||
#[test]
|
||||
fn patch_undo_helper_allows_an_untrusted_undo_with_nothing_to_roll_back() -> Result<()> {
|
||||
if git_missing() {
|
||||
return Ok(());
|
||||
}
|
||||
let _lock = lock_test_env();
|
||||
let root = tempfile::tempdir()?;
|
||||
let home = root.path().join("home");
|
||||
fs::create_dir_all(&home)?;
|
||||
let _home = EnvVarGuard::set("HOME", &home);
|
||||
|
||||
let workspace = root.path().join("workspace");
|
||||
fs::create_dir_all(&workspace)?;
|
||||
let repo = codewhale_snapshot::SnapshotRepo::open_or_init(&workspace)?;
|
||||
let file = workspace.join("a.txt");
|
||||
|
||||
fs::write(&file, "only-state")?;
|
||||
let pre = repo.take_snapshot("pre-turn:1", Some("thr_a"))?;
|
||||
// The turn ran a tool but changed nothing.
|
||||
let post = repo.take_snapshot("post-turn:1", Some("thr_a"))?;
|
||||
|
||||
let result = patch_undo_workspace_files(
|
||||
&workspace,
|
||||
&[dropped_turn(
|
||||
"turn_1",
|
||||
true,
|
||||
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
|
||||
)],
|
||||
false,
|
||||
)
|
||||
.expect("nothing to roll back is not a refusal");
|
||||
assert!(!result.files_restored);
|
||||
assert!(result.summary.is_some());
|
||||
assert_eq!(fs::read_to_string(&file)?, "only-state");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_of(path: &FsPath) -> String {
|
||||
format!(
|
||||
"sha256:{}",
|
||||
@@ -26506,8 +26447,9 @@ mod thread_snapshot_ownership {
|
||||
|
||||
/// An undo that cannot restore files fails honestly: snapshots disabled,
|
||||
/// a legacy seeded turn with tool calls and no receipts, a restore point
|
||||
/// pruned from the store, and an untrusted thread. Each is a 409 with a
|
||||
/// code, no fork, and unchanged files.
|
||||
/// pruned from the store. Each is a 409 with a code, no fork, and
|
||||
/// unchanged files. A default-posture thread (no trust, no Full Access)
|
||||
/// with an intact restore point is not among them: it restores.
|
||||
#[tokio::test]
|
||||
async fn undo_that_cannot_restore_files_is_refused() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
@@ -26590,7 +26532,7 @@ mod thread_snapshot_ownership {
|
||||
assert_eq!(fx.thread_count().await?, before + 1);
|
||||
drop(fx);
|
||||
|
||||
// Snapshots on again: a pruned restore point and an untrusted thread.
|
||||
// Snapshots on again: a pruned restore point, then a default-posture thread.
|
||||
let root = test_root("honest-on");
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
|
||||
let Some(fx) = fixture(&root, TestServerOverrides::default()).await? else {
|
||||
@@ -26612,9 +26554,9 @@ mod thread_snapshot_ownership {
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let before = fx.thread_count().await?;
|
||||
refused(&fx, untrusted, "restore_requires_trust").await?;
|
||||
assert_eq!(fx.read("u.txt").as_deref(), Some("u"));
|
||||
assert_eq!(fx.thread_count().await?, before);
|
||||
fx.undo_restoring(&untrusted, 0).await?;
|
||||
assert_eq!(fx.read("u.txt"), None);
|
||||
assert_eq!(fx.thread_count().await?, before + 1);
|
||||
let _ = &fx.root;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -828,3 +828,149 @@ async fn turn_detail(
|
||||
.and_then(|turns| turns.iter().find(|turn| turn["id"] == turn_id))
|
||||
.map_or_else(|| "turn absent".to_string(), Value::to_string))
|
||||
}
|
||||
|
||||
// ── Remote built-in execution ──
|
||||
|
||||
/// A catalog that serves exactly one built-in remotely, so the route's name
|
||||
/// gate and snapshot hand-off can be observed without the terminal host.
|
||||
struct RemoteBuiltinCatalog;
|
||||
|
||||
impl CommandCatalog for RemoteBuiltinCatalog {
|
||||
fn answers_to(&self, name: &str) -> bool {
|
||||
name == "system"
|
||||
}
|
||||
fn builtins(&self) -> Vec<BuiltinCommand> {
|
||||
vec![builtin("system", vec!["xitong".to_string()])]
|
||||
}
|
||||
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
|
||||
Vec::new()
|
||||
}
|
||||
fn serves_builtin(&self, name: &str) -> bool {
|
||||
matches!(name.trim_start_matches('/'), "system" | "xitong")
|
||||
}
|
||||
fn execute_builtin(
|
||||
&self,
|
||||
name: &str,
|
||||
_args: Option<&str>,
|
||||
host: &codewhale_engine::command_catalog::BuiltinCommandHostSnapshot,
|
||||
) -> Option<codewhale_engine::command_catalog::BuiltinCommandExecution> {
|
||||
if !self.serves_builtin(name) {
|
||||
return None;
|
||||
}
|
||||
Some(codewhale_engine::command_catalog::BuiltinCommandExecution {
|
||||
name: "system".to_string(),
|
||||
text: format!(
|
||||
"{}|{}|{}",
|
||||
host.mode_label,
|
||||
host.model,
|
||||
match &host.system_prompt {
|
||||
codewhale_command_contract::facets::DebugSystemPrompt::None => 0,
|
||||
codewhale_command_contract::facets::DebugSystemPrompt::Text(text) => text.len(),
|
||||
codewhale_command_contract::facets::DebugSystemPrompt::Blocks(blocks) =>
|
||||
blocks.iter().map(String::len).sum(),
|
||||
}
|
||||
),
|
||||
is_error: false,
|
||||
text_only: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// B5: `POST /v1/threads/{id}/commands/builtin` executes only the names the
|
||||
/// installed host serves, and answers everything else like an unknown name.
|
||||
#[tokio::test]
|
||||
async fn post_thread_builtin_command_serves_only_the_host_allowlist() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let temp = tempfile::tempdir()?;
|
||||
let token = "builtin-route-fixture-token";
|
||||
let root = temp.path().join("builtin-commands-route");
|
||||
let sessions_dir = root.join("sessions");
|
||||
let workspace = root.join("workspace");
|
||||
fs::create_dir_all(&workspace)?;
|
||||
let _catalog =
|
||||
codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(RemoteBuiltinCatalog));
|
||||
|
||||
let Some((addr, runtime_threads, handle)) = spawn_test_server_with_root_token_mobile_workspace(
|
||||
root,
|
||||
sessions_dir,
|
||||
Some(token.to_string()),
|
||||
false,
|
||||
workspace,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let thread = runtime_threads
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let url = format!("http://{addr}/v1/threads/{}/commands/builtin", thread.id);
|
||||
|
||||
let unauthorized = client
|
||||
.post(&url)
|
||||
.json(&json!({ "name": "system" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let cold = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "name": "system" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(cold.status(), StatusCode::CONFLICT, "cold engine");
|
||||
runtime_threads.get_engine(&thread.id).await?;
|
||||
|
||||
// Not on the host's remote surface: the same answer an unknown name gets.
|
||||
for name in ["balance", "cache", "not-a-command"] {
|
||||
let response = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "name": name }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{name}");
|
||||
}
|
||||
|
||||
// A served name (alias spelling included) reaches the host with the
|
||||
// thread's own state, and its text comes back unchanged.
|
||||
for name in ["system", "/system", "xitong"] {
|
||||
let body: Value = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "name": name, "args": "ignored" }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(body["name"], "system");
|
||||
assert_eq!(body["is_error"], false);
|
||||
assert_eq!(body["text_only"], true);
|
||||
// One snapshot field per segment, so a route that assembled the
|
||||
// wrong thread's state cannot pass.
|
||||
let text = body["text"].as_str().context("builtin text")?;
|
||||
let fields: Vec<&str> = text.split('|').collect();
|
||||
assert_eq!(fields.len(), 3, "{text}");
|
||||
assert!(!fields[0].is_empty(), "mode label: {text}");
|
||||
assert_eq!(fields[1], thread.model, "model: {text}");
|
||||
let prompt_len: usize = fields[2].parse().context("system prompt length")?;
|
||||
assert!(prompt_len > 0, "effective system prompt reported: {text}");
|
||||
}
|
||||
|
||||
// The thread id is still the address: a missing thread stays 404.
|
||||
let missing = client
|
||||
.post(format!(
|
||||
"http://{addr}/v1/threads/thr_missing/commands/builtin"
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "name": "system" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use super::*;
|
||||
use reqwest::StatusCode as HttpStatus;
|
||||
|
||||
fn options_fixture() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../engine/tests/fixtures/acp-options-agent.mjs")
|
||||
.canonicalize()
|
||||
.expect("the options fixture agent")
|
||||
}
|
||||
|
||||
fn with_query(base: &str, pairs: &[(&str, &str)]) -> reqwest::Url {
|
||||
let mut url = reqwest::Url::parse(base).expect("a url");
|
||||
for (key, value) in pairs {
|
||||
url.query_pairs_mut().append_pair(key, value);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fn received(workspace: &Path) -> Vec<Value> {
|
||||
fs::read_to_string(workspace.join("options-received.jsonl"))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str(line).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn wait_for_usage_total(
|
||||
manager: &SharedRuntimeThreadManager,
|
||||
thread_id: &str,
|
||||
) -> Result<RuntimeEventRecord> {
|
||||
for _ in 0..600 {
|
||||
let events = manager.events_since_async(thread_id, None).await?;
|
||||
if let Some(event) = events.into_iter().find(|event| {
|
||||
event.event == "turn.agent_usage" && event.payload["kind"] == "turn_total"
|
||||
}) {
|
||||
return Ok(event);
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
bail!("the agent's usage was never recorded")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_options_route_offers_the_agents_choices_and_a_chosen_one_reaches_it() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let _fixture = EnvVarGuard::set("CODEWHALE_ACP_FIXTURE_AGENT", options_fixture());
|
||||
let Some((addr, manager, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let cwd = workspace.path().canonicalize()?;
|
||||
let url = format!("http://{addr}/v1/agents/external/fixture/options");
|
||||
|
||||
let offered: Value = client
|
||||
.get(with_query(&url, &[("cwd", cwd.to_str().unwrap())]))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(offered["agent"]["id"], "fixture");
|
||||
let controls = offered["controls"].as_array().unwrap();
|
||||
let model = controls.iter().find(|c| c["id"] == "model").unwrap();
|
||||
let values: Vec<&str> = model["choices"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["value"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(values.len() >= 2, "{values:?}");
|
||||
assert!(controls.iter().any(|c| c["id"] == "mode"));
|
||||
assert!(
|
||||
controls.iter().any(|c| c["id"] == "config:verbose"
|
||||
&& c["unavailable"]["code"] == "control_type_unsupported")
|
||||
);
|
||||
assert!(
|
||||
offered["actions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|a| a["action"] == "steering" && a["supported"] == false)
|
||||
);
|
||||
let leaked = fs::read_to_string(cwd.join("options-env.json"))?;
|
||||
assert!(!leaked.contains("CODEWHALE_ACP_FIXTURE_AGENT"));
|
||||
|
||||
for (query, status, code) in [
|
||||
(vec![], HttpStatus::BAD_REQUEST, None),
|
||||
(vec![("cwd", "relative/dir")], HttpStatus::BAD_REQUEST, None),
|
||||
(
|
||||
vec![("cwd", "/definitely/not/here")],
|
||||
HttpStatus::NOT_FOUND,
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let response = client.get(with_query(&url, &query)).send().await?;
|
||||
assert_eq!(response.status(), status, "{query:?}");
|
||||
let body: Value = response.json().await?;
|
||||
assert_eq!(body["error"]["code"].as_str(), code, "{body}");
|
||||
}
|
||||
let unknown = client
|
||||
.get(with_query(
|
||||
&format!("http://{addr}/v1/agents/external/nope/options"),
|
||||
&[("cwd", cwd.to_str().unwrap())],
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(unknown.status(), HttpStatus::NOT_FOUND);
|
||||
let body: Value = unknown.json().await?;
|
||||
assert_eq!(body["error"]["code"], "unknown_agent");
|
||||
let bad_id = client
|
||||
.get(with_query(
|
||||
&format!("http://{addr}/v1/agents/external/..%2Fsh/options"),
|
||||
&[("cwd", cwd.to_str().unwrap())],
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(bad_id.status().is_client_error());
|
||||
|
||||
let chosen = values[1];
|
||||
let created: Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"workspace": cwd,
|
||||
"permission_posture": "ask",
|
||||
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": chosen}}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let thread_id = created["id"].as_str().unwrap().to_string();
|
||||
|
||||
let refused = client
|
||||
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
|
||||
.json(&json!({"prompt": "x", "model": "other"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(refused.status(), HttpStatus::FAILED_DEPENDENCY);
|
||||
let body: Value = refused.json().await?;
|
||||
assert_eq!(body["error"]["code"], "unsupported_control");
|
||||
|
||||
client
|
||||
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
|
||||
.json(&json!({"prompt": "hello"}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let total = wait_for_usage_total(&manager, &thread_id).await?;
|
||||
assert_eq!(total.payload["source"], "agent reported");
|
||||
assert_eq!(total.payload["cost"], "unknown");
|
||||
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
|
||||
let log = received(&cwd);
|
||||
let prompt = log
|
||||
.iter()
|
||||
.find(|e| e["method"] == "session/prompt")
|
||||
.unwrap();
|
||||
assert_eq!(prompt["state"]["model"], chosen);
|
||||
|
||||
let stale = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"workspace": cwd,
|
||||
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": "model-z"}}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Value>()
|
||||
.await?;
|
||||
let stale_id = stale["id"].as_str().unwrap();
|
||||
let response = client
|
||||
.post(format!("http://{addr}/v1/threads/{stale_id}/turns"))
|
||||
.json(&json!({"prompt": "x"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.status(), HttpStatus::FAILED_DEPENDENCY);
|
||||
let body: Value = response.json().await?;
|
||||
assert_eq!(body["error"]["code"], "choice_not_offered");
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use super::*;
|
||||
use reqwest::StatusCode as HttpStatus;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const FIXTURE_TOKEN: &str = "cw-fixture-token-7993-do-not-echo";
|
||||
|
||||
fn reasons(body: &Value) -> BTreeMap<String, String> {
|
||||
body["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
assert!(
|
||||
entry["quota"].get("source").is_none(),
|
||||
"no source without a reader: {entry}"
|
||||
);
|
||||
assert_eq!(entry["quota"]["status"], "unknown", "{entry}");
|
||||
(
|
||||
entry["id"].as_str().expect("id").to_string(),
|
||||
entry["quota"]["reason"]
|
||||
.as_str()
|
||||
.expect("reason")
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot(paths: &[PathBuf]) -> Vec<(Vec<u8>, std::time::SystemTime)> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
(
|
||||
fs::read(path).expect("fixture credential"),
|
||||
fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.expect("mtime"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_quota_reads_nothing_until_flag_and_consent_and_never_echoes_a_token() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let root = tempfile::tempdir()?;
|
||||
let home = root.path().join("home");
|
||||
let mut credentials = Vec::new();
|
||||
for (dir, file) in [
|
||||
(".claude", ".credentials.json"),
|
||||
(".codex", "auth.json"),
|
||||
(".gemini", "oauth_creds.json"),
|
||||
(".kimi", "credentials.json"),
|
||||
(".grok", "auth.json"),
|
||||
] {
|
||||
let dir = home.join(dir);
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(file);
|
||||
fs::write(
|
||||
&path,
|
||||
format!(r#"{{"access_token":"{FIXTURE_TOKEN}","refresh_token":"{FIXTURE_TOKEN}"}}"#),
|
||||
)?;
|
||||
credentials.push(path);
|
||||
}
|
||||
let before = snapshot(&credentials);
|
||||
let config_file = root.path().join("config").join("config.toml");
|
||||
fs::create_dir_all(config_file.parent().unwrap())?;
|
||||
fs::write(
|
||||
&config_file,
|
||||
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\n",
|
||||
)?;
|
||||
let _home = EnvVarGuard::set("HOME", &home);
|
||||
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_config_path(config_file.clone()).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let mut seen = String::new();
|
||||
|
||||
let read = || {
|
||||
let client = client.clone();
|
||||
let url = format!("http://{addr}/v1/agents/external/quota");
|
||||
async move {
|
||||
let text = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.text()
|
||||
.await?;
|
||||
Ok::<String, anyhow::Error>(text)
|
||||
}
|
||||
};
|
||||
let set = |key: &'static str, value: &'static str| {
|
||||
let client = client.clone();
|
||||
let url = format!("http://{addr}/v1/config");
|
||||
async move {
|
||||
let response = client
|
||||
.post(url)
|
||||
.json(&json!({"key": key, "value": value, "persist": true}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
Ok::<(HttpStatus, String), anyhow::Error>((status, response.text().await?))
|
||||
}
|
||||
};
|
||||
|
||||
let text = read().await?;
|
||||
seen.push_str(&text);
|
||||
let body: Value = serde_json::from_str(&text)?;
|
||||
assert_eq!(body["default_source"], "agent_reported");
|
||||
let initial = reasons(&body);
|
||||
assert_eq!(initial.len(), codewhale_engine::fleet::agents::AGENTS.len());
|
||||
assert!(
|
||||
initial.values().all(|reason| reason == "flag_off"),
|
||||
"{initial:?}"
|
||||
);
|
||||
|
||||
for (key, value) in [
|
||||
("agents.quota.claude", "on"),
|
||||
("agents.quota.codex.consent", "granted"),
|
||||
] {
|
||||
let (status, text) = set(key, value).await?;
|
||||
seen.push_str(&text);
|
||||
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
|
||||
}
|
||||
let text = read().await?;
|
||||
seen.push_str(&text);
|
||||
let gated = reasons(&serde_json::from_str(&text)?);
|
||||
assert_eq!(gated["claude"], "consent_missing");
|
||||
assert_eq!(gated["codex"], "flag_off");
|
||||
assert_eq!(gated["grok"], "flag_off");
|
||||
|
||||
for (key, value) in [
|
||||
("agents.quota.claude.consent", "granted"),
|
||||
("agents.quota.codex", "true"),
|
||||
] {
|
||||
let (status, text) = set(key, value).await?;
|
||||
seen.push_str(&text);
|
||||
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
|
||||
}
|
||||
let saved = codewhale_runtime::settings::Settings::load_persisted()?;
|
||||
assert!(saved.agents.quota("claude").enabled);
|
||||
assert!(saved.agents.quota("claude").consented_at.is_some());
|
||||
let text = read().await?;
|
||||
seen.push_str(&text);
|
||||
let consented = reasons(&serde_json::from_str(&text)?);
|
||||
assert_eq!(consented["claude"], "not_implemented");
|
||||
assert_eq!(consented["codex"], "not_implemented");
|
||||
assert_eq!(consented["opencode"], "flag_off");
|
||||
|
||||
for (key, value) in [
|
||||
("agents.quota.claude.consent", "revoked"),
|
||||
("agents.quota.codex", "off"),
|
||||
] {
|
||||
let (status, text) = set(key, value).await?;
|
||||
seen.push_str(&text);
|
||||
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
|
||||
}
|
||||
let text = read().await?;
|
||||
seen.push_str(&text);
|
||||
let revoked = reasons(&serde_json::from_str(&text)?);
|
||||
assert_eq!(revoked["claude"], "consent_missing");
|
||||
assert_eq!(revoked["codex"], "flag_off");
|
||||
|
||||
for (key, value) in [
|
||||
("agents.quota.claude.consent", FIXTURE_TOKEN),
|
||||
("agents.quota.Claude/../x", "on"),
|
||||
("agents.quota.claude", FIXTURE_TOKEN),
|
||||
] {
|
||||
let (status, text) = set(key, value).await?;
|
||||
assert_eq!(status, HttpStatus::BAD_REQUEST, "{key}: {text}");
|
||||
seen.push_str(&text);
|
||||
}
|
||||
|
||||
let (status, text) = set("agents.quota.not-an-agent", "on").await?;
|
||||
seen.push_str(&text);
|
||||
assert_eq!(status, HttpStatus::OK, "{text}");
|
||||
let text = read().await?;
|
||||
seen.push_str(&text);
|
||||
assert_eq!(
|
||||
reasons(&serde_json::from_str(&text)?)["not-an-agent"],
|
||||
"unknown_agent"
|
||||
);
|
||||
|
||||
let settings_file = fs::read_to_string(config_file.with_file_name("settings.toml"))?;
|
||||
assert!(
|
||||
settings_file.contains("[agents.quota.claude]"),
|
||||
"{settings_file}"
|
||||
);
|
||||
assert!(!settings_file.contains(FIXTURE_TOKEN));
|
||||
assert!(
|
||||
!seen.contains(FIXTURE_TOKEN),
|
||||
"a response echoed the fixture token"
|
||||
);
|
||||
assert_eq!(snapshot(&credentials), before, "a credential file changed");
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use super::*;
|
||||
use codewhale_engine::llm_client::mock::MockLlmClient;
|
||||
use codewhale_engine::runtime_threads::job_operations::{
|
||||
MEMBER_RESERVED, MemberOperation, MemberOperationRecord,
|
||||
};
|
||||
|
||||
struct AbortOnDrop(tokio::task::AbortHandle);
|
||||
impl Drop for AbortOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn fleet() -> Value {
|
||||
json!({
|
||||
"schema": "fleet",
|
||||
"schema_revision": 2,
|
||||
"name": "Review team",
|
||||
"members": [
|
||||
{ "id": "reviewer", "role": "reviewer", "provider": "deepseek", "model": "deepseek-v4-pro" }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn start_body(key: &str, prompt: &str) -> Value {
|
||||
json!({
|
||||
"operation_key": key,
|
||||
"member_id": "reviewer",
|
||||
"display_name": "Reviewer",
|
||||
"prompt": prompt,
|
||||
"fleet": fleet(),
|
||||
})
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
threads: &SharedRuntimeThreadManager,
|
||||
thread_id: &str,
|
||||
key: &str,
|
||||
generation: &str,
|
||||
) -> Result<()> {
|
||||
threads
|
||||
.job_operation_store()
|
||||
.with_job_operations(|transaction| {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
transaction.save_member(&MemberOperationRecord {
|
||||
schema_version: 1,
|
||||
operation: MemberOperation {
|
||||
id: transaction.member_operation_id(thread_id, key)?,
|
||||
store_id: threads.job_operation_store_id().to_string(),
|
||||
thread_id: thread_id.to_string(),
|
||||
member_id: "reviewer".to_string(),
|
||||
operation_key: key.to_string(),
|
||||
agent_id: "agent_reservedfixture".to_string(),
|
||||
state: MEMBER_RESERVED.to_string(),
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
},
|
||||
request_fingerprint: "a".repeat(64),
|
||||
owner_generation: generation.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_starts_are_durable_replayable_and_refuse_foreign_or_mismatched_replay() -> Result<()>
|
||||
{
|
||||
let _env = lock_test_env();
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let root = temporary.path().canonicalize()?;
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
|
||||
let workspace = root.join("workspace");
|
||||
fs::create_dir_all(&workspace)?;
|
||||
let config = Config::default().with_legacy_root(
|
||||
Some("member-fixture-key".into()),
|
||||
Some("http://127.0.0.1:1/v1".into()),
|
||||
);
|
||||
let (addr, threads, server) = spawn_test_server_with_root_token_mobile_workspace_and_overrides(
|
||||
root.clone(),
|
||||
root.join("sessions"),
|
||||
None,
|
||||
false,
|
||||
workspace.clone(),
|
||||
TestServerOverrides {
|
||||
config: Some(config),
|
||||
..TestServerOverrides::default()
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.context("the member fixture needs a loopback listener")?;
|
||||
let _server = AbortOnDrop(server.abort_handle());
|
||||
threads.set_test_model_client(Arc::new(MockLlmClient::new(Vec::new())));
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let url = |path: &str| format!("http://{addr}{path}");
|
||||
let create_thread = || async {
|
||||
let created: Value = client
|
||||
.post(url("/v1/threads"))
|
||||
.json(&json!({ "model": "deepseek-v4-pro", "mode": "agent", "workspace": workspace }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
anyhow::Ok(created["id"].as_str().context("thread id")?.to_string())
|
||||
};
|
||||
let thread = create_thread().await?;
|
||||
let other = create_thread().await?;
|
||||
let info: Value = client
|
||||
.get(url("/v1/runtime/info"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
info["capabilities"]["thread_fleet_member_durable_operations_v1"],
|
||||
true
|
||||
);
|
||||
let store_id = threads.job_operation_store_id().to_string();
|
||||
let members = url(&format!("/v1/threads/{thread}/members"));
|
||||
|
||||
let first = client
|
||||
.post(&members)
|
||||
.json(&start_body("team-start-1", "Review the diff"))
|
||||
.send()
|
||||
.await?;
|
||||
let status = first.status();
|
||||
let receipt: Value = first.json().await?;
|
||||
assert_eq!(status, StatusCode::OK, "{receipt}");
|
||||
assert_eq!(receipt["state"], "accepted");
|
||||
assert_eq!(receipt["runtime_store_id"], store_id.as_str());
|
||||
assert_eq!(receipt["operation_key"], "team-start-1");
|
||||
let agent_id = receipt["agent_id"]
|
||||
.as_str()
|
||||
.context("agent id")?
|
||||
.to_string();
|
||||
let runs = member_runs(&client, addr, &thread).await?;
|
||||
assert_eq!(runs.len(), 1, "{runs:?}");
|
||||
assert_eq!(
|
||||
runs[0]["spec"]["worker_id"],
|
||||
agent_id.as_str(),
|
||||
"the receipt names the worker it started"
|
||||
);
|
||||
|
||||
let replayed: Value = client
|
||||
.post(&members)
|
||||
.json(&start_body("team-start-1", "Review the diff"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
replayed, receipt,
|
||||
"exact replay returns the original receipt"
|
||||
);
|
||||
let read: Value = client
|
||||
.get(url(&format!(
|
||||
"/v1/threads/{thread}/members/operations/team-start-1"
|
||||
)))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(read, receipt);
|
||||
|
||||
let mismatched = client
|
||||
.post(&members)
|
||||
.json(&start_body("team-start-1", "Review something else"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(mismatched.status(), StatusCode::CONFLICT);
|
||||
let body: Value = mismatched.json().await?;
|
||||
assert_eq!(body["error"]["code"], "member_operation_mismatch");
|
||||
|
||||
let foreign = client
|
||||
.get(url(&format!(
|
||||
"/v1/threads/{other}/members/operations/team-start-1"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
foreign.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a key never crosses threads"
|
||||
);
|
||||
|
||||
let mut ghost = start_body("team-ghost", "Review the diff");
|
||||
ghost["member_id"] = json!("ghost");
|
||||
let refused = client.post(&members).json(&ghost).send().await?;
|
||||
assert_eq!(refused.status(), StatusCode::CONFLICT);
|
||||
let withdrawn = client
|
||||
.get(url(&format!(
|
||||
"/v1/threads/{thread}/members/operations/team-ghost"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
withdrawn.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a start the Engine refused withdraws its reservation"
|
||||
);
|
||||
|
||||
let mut stale = start_body("team-start-2", "Review the diff");
|
||||
stale["expected_store_id"] = json!("another-store");
|
||||
let stale = client.post(&members).json(&stale).send().await?;
|
||||
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
||||
let body: Value = stale.json().await?;
|
||||
assert_eq!(body["error"]["code"], "member_operation_store_changed");
|
||||
|
||||
let mut forged = start_body("team-start-3", "Review the diff");
|
||||
forged["request_fingerprint"] = json!("b".repeat(64));
|
||||
let forged = client.post(&members).json(&forged).send().await?;
|
||||
assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let generation = threads.job_operation_generation()?;
|
||||
reserve(&threads, &other, "in-flight", &generation)?;
|
||||
let deleted = threads.delete_thread(&other).await;
|
||||
assert!(
|
||||
deleted
|
||||
.as_ref()
|
||||
.is_err_and(|error| error.to_string().contains("pending member operation")),
|
||||
"a thread with a start in flight is not deleted: {deleted:?}"
|
||||
);
|
||||
|
||||
reserve(
|
||||
&threads,
|
||||
&other,
|
||||
"before-restart",
|
||||
&Uuid::new_v4().to_string(),
|
||||
)?;
|
||||
let abandoned: Value = client
|
||||
.get(url(&format!(
|
||||
"/v1/threads/{other}/members/operations/before-restart"
|
||||
)))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
abandoned["state"], "abandoned",
|
||||
"a reservation an earlier Engine never started is settled, not left unknown"
|
||||
);
|
||||
|
||||
let cancelled = client
|
||||
.post(url(&format!("/v1/agent-runs/{agent_id}/cancel")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(cancelled.status().is_success(), "{}", cancelled.status());
|
||||
let after_cancel: Value = client
|
||||
.post(&members)
|
||||
.json(&start_body("team-start-1", "Review the diff"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
after_cancel["agent_id"], agent_id,
|
||||
"owner cancellation never makes a replay start a second worker"
|
||||
);
|
||||
assert_eq!(member_runs(&client, addr, &thread).await?.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn member_runs(
|
||||
client: &reqwest::Client,
|
||||
addr: SocketAddr,
|
||||
thread: &str,
|
||||
) -> Result<Vec<Value>> {
|
||||
let listed: Value = client
|
||||
.get(format!("http://{addr}/v1/agent-runs"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(listed["runs"]
|
||||
.as_array()
|
||||
.context("runs")?
|
||||
.iter()
|
||||
.filter(|run| run["owner_session_id"] == thread)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use super::*;
|
||||
|
||||
const SEATS: [&str; 6] = [
|
||||
"customize.plugins.detail",
|
||||
"conversation.composer.dock",
|
||||
"conversation.chat.node",
|
||||
"workpane.panel",
|
||||
"sidebar.section",
|
||||
"command.row",
|
||||
];
|
||||
|
||||
const CONSUMER_BRIDGE_KEYS: [&str; 7] = [
|
||||
"kind",
|
||||
"dsh_version",
|
||||
"cordis_version",
|
||||
"compatibility_digest",
|
||||
"declared_range",
|
||||
"matched_version",
|
||||
"admission",
|
||||
];
|
||||
|
||||
const CONSUMER_REFUSALS: [(u16, &str); 6] = [
|
||||
(404, "plugin_ui_not_declared"),
|
||||
(404, "plugin_ui_seat_not_admitted"),
|
||||
(400, "plugin_ui_unsupported"),
|
||||
(422, "plugin_ui_seat_unsupported"),
|
||||
(400, "plugin_ui_seat_unknown"),
|
||||
(404, "plugin_ui_file_not_reviewed"),
|
||||
];
|
||||
|
||||
const DSH_CLIENT: &str = "const react = require(\"react\");\n\
|
||||
const util = require(\"./util\");\n\
|
||||
const keys = [\"sidebar.right.pane.tab\", \"settings.section\", \"conversation.session.header.utilities\"];\n\
|
||||
exports.apply = function apply(ctx) { return [react, util.v, keys, ctx]; };\n";
|
||||
|
||||
fn write_dsh_package(dir: &Path, name: &str, client: &str) -> Result<PathBuf> {
|
||||
let package = dir.join(name);
|
||||
fs::create_dir_all(package.join("lib"))?;
|
||||
fs::write(
|
||||
package.join("package.json"),
|
||||
serde_json::json!({
|
||||
"name": format!("@demo/{name}"),
|
||||
"version": "1.0.0",
|
||||
"exports": { "./client": { "default": "./lib/client.js" } },
|
||||
"dsh": {
|
||||
"bundle": { "patch": "./cordis.patch.yml" },
|
||||
"client": { "platform": "web" }
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)?;
|
||||
fs::write(
|
||||
package.join("cordis.patch.yml"),
|
||||
"- insert:\n - id: skills\n name: \"@deepseek-ai/dsh-skill-filesystem\"\n config:\n customSkillDirs:\n - ./skills\n",
|
||||
)?;
|
||||
fs::create_dir_all(package.join("skills/greet"))?;
|
||||
fs::write(
|
||||
package.join("skills/greet/SKILL.md"),
|
||||
"---\nname: greet\ndescription: Greets a person\n---\nSay hi.\n",
|
||||
)?;
|
||||
fs::write(package.join("lib/client.js"), client)?;
|
||||
fs::write(package.join("lib/util.js"), "exports.v = 1;\n")?;
|
||||
Ok(package)
|
||||
}
|
||||
|
||||
fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
|
||||
let bundle = write_plugin_source_bundle(dir, name)?;
|
||||
fs::write(
|
||||
bundle.join("package.json"),
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"exports": { "./client": "./client.js" },
|
||||
"dsh": { "client": { "platform": "web" } }
|
||||
})
|
||||
.to_string(),
|
||||
)?;
|
||||
fs::write(bundle.join("client.js"), "window.__native = true;\n")?;
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
async fn install_active(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
source: String,
|
||||
) -> Result<serde_json::Value> {
|
||||
let install: serde_json::Value = client
|
||||
.post(format!("{base}/install"))
|
||||
.json(&serde_json::json!({ "source": source }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let name = install["name"]
|
||||
.as_str()
|
||||
.expect("installed name")
|
||||
.to_string();
|
||||
let detail: serde_json::Value = client
|
||||
.get(format!("{base}/{name}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let token = detail["review"]["token"].as_str().unwrap().to_string();
|
||||
client
|
||||
.post(format!("{base}/{name}/trust"))
|
||||
.json(&serde_json::json!({ "token": token }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let enabled: serde_json::Value = client
|
||||
.post(format!("{base}/{name}/enable"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(enabled["state"], "active", "{enabled}");
|
||||
Ok(serde_json::json!({ "name": name, "path": detail["path"] }))
|
||||
}
|
||||
|
||||
async fn ui(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
name: &str,
|
||||
query: &str,
|
||||
) -> Result<(u16, serde_json::Value)> {
|
||||
let response = client
|
||||
.get(format!("{base}/{name}/ui?{query}"))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status().as_u16();
|
||||
Ok((status, response.json().await?))
|
||||
}
|
||||
|
||||
fn refusal(status: u16, body: &serde_json::Value) -> (u16, String) {
|
||||
(
|
||||
status,
|
||||
body["error"]["code"].as_str().unwrap_or("").to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_consumer_contract(name: &str, minted: &serde_json::Value, seat: &str, key: &str) {
|
||||
assert_eq!(minted["slot"], seat);
|
||||
assert_eq!(minted["dsh_slot"], key);
|
||||
assert!(SEATS.contains(&minted["slot"].as_str().unwrap()));
|
||||
let key = minted["dsh_slot"].as_str().unwrap();
|
||||
assert!(key.len() <= 128 && key.chars().next().unwrap().is_ascii_alphanumeric());
|
||||
assert!(
|
||||
key.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
);
|
||||
let bridge = minted["bridge"].as_object().expect("bridge object");
|
||||
assert_eq!(bridge["kind"], "dsh-client");
|
||||
for (field, value) in bridge {
|
||||
assert!(CONSUMER_BRIDGE_KEYS.contains(&field.as_str()), "{field}");
|
||||
assert!(
|
||||
value
|
||||
.as_str()
|
||||
.is_some_and(|v| !v.is_empty() && v.len() <= 128)
|
||||
);
|
||||
}
|
||||
assert!(bridge.contains_key("dsh_version"));
|
||||
let base = minted["base"].as_str().unwrap();
|
||||
assert!(base.starts_with("http://127.0.0.1:"));
|
||||
assert!(base.ends_with(&format!(
|
||||
"/v1/apps/plugins/{}/ui/",
|
||||
urlencoding::encode(name)
|
||||
)));
|
||||
let entry = minted["entry"].as_str().unwrap();
|
||||
assert!(!entry.is_empty() && entry.len() <= 512 && !entry.contains(".."));
|
||||
assert!(
|
||||
minted["revision"]
|
||||
.as_str()
|
||||
.is_some_and(|r| r.starts_with('"'))
|
||||
);
|
||||
}
|
||||
|
||||
fn bootstrap_loads_in_node(source: &str) -> Option<String> {
|
||||
let dir = tempfile::tempdir().ok()?;
|
||||
let script = dir.path().join("run.js");
|
||||
let harness = format!(
|
||||
"var captured;\nvar window = {{ __ModuleLoader__: {{ load: function (r) {{ captured = r; }} }} }};\n{source}\nvar mod = captured.factory(function (id) {{ return {{ id: id }}; }});\nprocess.stdout.write(JSON.stringify({{ id: captured.id, apply: typeof mod.apply, out: mod.apply('ctx') }}));\n"
|
||||
);
|
||||
fs::write(&script, harness).ok()?;
|
||||
let output = std::process::Command::new("node")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.ok()?;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"the served bootstrap failed under node: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_ui_dsh_client_serves_admitted_seats_and_denies_and_revokes() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().join("ws");
|
||||
fs::create_dir_all(&root)?;
|
||||
let package = write_dsh_package(tmp.path(), "panel", DSH_CLIENT)?;
|
||||
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let base = format!("http://{addr}/v1/apps/plugins");
|
||||
let installed = install_active(&client, &base, format!("dsh:{}", package.display())).await?;
|
||||
let name = installed["name"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, panel) = ui(&client, &base, &name, "slot=workpane.panel").await?;
|
||||
assert_eq!(status, 200, "{panel}");
|
||||
assert_consumer_contract(&name, &panel, "workpane.panel", "sidebar.right.pane.tab");
|
||||
assert_eq!(panel["entry"], "__codewhale_dsh_bootstrap.js");
|
||||
let (status, detail) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
|
||||
assert_eq!(status, 200, "{detail}");
|
||||
assert_consumer_contract(
|
||||
&name,
|
||||
&detail,
|
||||
"customize.plugins.detail",
|
||||
"settings.section",
|
||||
);
|
||||
let (status, explicit) = ui(
|
||||
&client,
|
||||
&base,
|
||||
&name,
|
||||
"slot=workpane.panel&dsh_slot=sidebar.right.pane.tab",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 200, "{explicit}");
|
||||
|
||||
for (query, expected) in [
|
||||
("slot=sidebar.section", (404, "plugin_ui_seat_not_admitted")),
|
||||
("slot=command.row", (404, "plugin_ui_seat_not_admitted")),
|
||||
(
|
||||
"slot=workpane.panel&dsh_slot=main.page-menu",
|
||||
(404, "plugin_ui_seat_not_admitted"),
|
||||
),
|
||||
(
|
||||
"slot=workpane.panel&dsh_slot=settings.section",
|
||||
(404, "plugin_ui_seat_not_admitted"),
|
||||
),
|
||||
(
|
||||
"slot=workpane.panel&dsh_slot=conversation.session.header.utilities",
|
||||
(422, "plugin_ui_seat_unsupported"),
|
||||
),
|
||||
("slot=chat.node", (400, "plugin_ui_seat_unknown")),
|
||||
("dsh_slot=settings.section", (400, "plugin_ui_seat_unknown")),
|
||||
(
|
||||
"slot=workpane.panel&dsh_slot=..%2Fx",
|
||||
(400, "plugin_ui_dsh_slot_invalid"),
|
||||
),
|
||||
] {
|
||||
let (status, body) = ui(&client, &base, &name, query).await?;
|
||||
let (status, code) = refusal(status, &body);
|
||||
assert_eq!((status, code.as_str()), expected, "{query}: {body}");
|
||||
assert!(
|
||||
CONSUMER_REFUSALS.contains(&(status, code.as_str()))
|
||||
|| code == "plugin_ui_dsh_slot_invalid",
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
|
||||
let revision = panel["revision"].as_str().unwrap().to_string();
|
||||
let asset_url = format!(
|
||||
"{}{}",
|
||||
panel["base"].as_str().unwrap(),
|
||||
panel["entry"].as_str().unwrap()
|
||||
);
|
||||
let served = client
|
||||
.get(&asset_url)
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(served.status(), StatusCode::OK);
|
||||
assert!(
|
||||
served
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/javascript"))
|
||||
);
|
||||
let bootstrap = served.text().await?;
|
||||
assert!(bootstrap.contains("\"source/lib/util.js\""));
|
||||
assert!(!bootstrap.contains("Bearer"));
|
||||
match bootstrap_loads_in_node(&bootstrap) {
|
||||
Some(out) => {
|
||||
let out: serde_json::Value = serde_json::from_str(&out)?;
|
||||
assert_eq!(out["apply"], "function");
|
||||
assert_eq!(out["out"][0]["id"], "react");
|
||||
assert_eq!(out["out"][1], 1);
|
||||
}
|
||||
None => eprintln!("node is unavailable; the bootstrap execution check was skipped"),
|
||||
}
|
||||
|
||||
let asset_base = panel["base"].as_str().unwrap().to_string();
|
||||
let missing = client.get(&asset_url).send().await?;
|
||||
assert_eq!(missing.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||
let wrong = client
|
||||
.get(&asset_url)
|
||||
.header("If-Match", "\"not-a-revision\"")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(wrong.status(), StatusCode::PRECONDITION_FAILED);
|
||||
for outside in [
|
||||
"package.json",
|
||||
"source/package.json",
|
||||
"source/lib/index.cjs",
|
||||
"source/lib/util.js",
|
||||
] {
|
||||
let response = client
|
||||
.get(format!("{asset_base}{outside}"))
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{outside}");
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
assert_eq!(body["error"]["code"], "plugin_ui_file_not_reviewed");
|
||||
}
|
||||
let traversal = client
|
||||
.get(format!("{asset_base}%2e%2e/%2e%2e/etc/passwd"))
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(traversal.status().is_client_error());
|
||||
|
||||
let route = format!("{base}/{name}/routes/api/state");
|
||||
let routed = client
|
||||
.post(&route)
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(routed.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body: serde_json::Value = routed.json().await?;
|
||||
assert_eq!(body["error"]["code"], "plugin_route_not_served");
|
||||
assert_eq!(
|
||||
client.post(&route).send().await?.status(),
|
||||
StatusCode::PRECONDITION_REQUIRED
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.post(&route)
|
||||
.header("If-Match", "\"not-a-revision\"")
|
||||
.send()
|
||||
.await?
|
||||
.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.post(format!("{base}/other/routes/api/state"))
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?
|
||||
.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.post(format!("{base}/{name}/routes/%2e%2e/x"))
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?
|
||||
.status()
|
||||
.is_client_error()
|
||||
);
|
||||
|
||||
let disabled = client.post(format!("{base}/{name}/disable")).send().await?;
|
||||
assert_eq!(disabled.status(), StatusCode::OK);
|
||||
for url in [&asset_url, &route] {
|
||||
let response = client.get(url).header("If-Match", &revision).send().await?;
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED, "{url}");
|
||||
}
|
||||
let enabled = client.post(format!("{base}/{name}/enable")).send().await?;
|
||||
assert_eq!(enabled.status(), StatusCode::OK);
|
||||
let stale = client
|
||||
.get(&asset_url)
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
let (status, again) = ui(&client, &base, &name, "slot=workpane.panel").await?;
|
||||
assert_eq!(status, 200, "{again}");
|
||||
let again_revision = again["revision"].as_str().unwrap().to_string();
|
||||
assert_ne!(again_revision, revision);
|
||||
let staged = PathBuf::from(installed["path"].as_str().unwrap()).join("source/lib/util.js");
|
||||
let live = client
|
||||
.get(&asset_url)
|
||||
.header("If-Match", &again_revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(live.status(), StatusCode::OK);
|
||||
fs::write(&staged, "exports.v = 2;\n")?;
|
||||
let tampered = client
|
||||
.get(&asset_url)
|
||||
.header("If-Match", &again_revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(tampered.status(), StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_ui_dsh_client_with_unprovided_modules_or_ambient_dependencies_is_unsupported()
|
||||
-> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().join("ws");
|
||||
fs::create_dir_all(&root)?;
|
||||
let host_module = write_dsh_package(
|
||||
tmp.path(),
|
||||
"hostmod",
|
||||
"require(\"@deepseek-ai/dsh-client-ui-primitives\");\nconst k = \"sidebar.right.pane.tab\";\n",
|
||||
)?;
|
||||
let esm = write_dsh_package(
|
||||
tmp.path(),
|
||||
"esm",
|
||||
"const k = \"sidebar.right.pane.tab\";\nexport const apply = () => k;\n",
|
||||
)?;
|
||||
let ambient = write_dsh_package(
|
||||
tmp.path(),
|
||||
"ambient",
|
||||
"require(\"left-pad\");\nconst k = \"sidebar.right.pane.tab\";\n",
|
||||
)?;
|
||||
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let base = format!("http://{addr}/v1/apps/plugins");
|
||||
for package in [host_module, esm, ambient] {
|
||||
let installed =
|
||||
install_active(&client, &base, format!("dsh:{}", package.display())).await?;
|
||||
let name = installed["name"].as_str().unwrap();
|
||||
let (status, body) = ui(&client, &base, name, "slot=workpane.panel").await?;
|
||||
assert_eq!(
|
||||
refusal(status, &body),
|
||||
(400, "plugin_ui_unsupported".to_string()),
|
||||
"{name}: {body}"
|
||||
);
|
||||
}
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().join("ws");
|
||||
fs::create_dir_all(&root)?;
|
||||
let bundle = write_native_ui_bundle(tmp.path(), "nativeui")?;
|
||||
|
||||
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = codewhale_release::tls::reqwest_client();
|
||||
let base = format!("http://{addr}/v1/apps/plugins");
|
||||
let installed = install_active(&client, &base, format!("path:{}", bundle.display())).await?;
|
||||
let name = installed["name"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, minted) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
|
||||
assert_eq!(status, 200, "{minted}");
|
||||
assert!(minted.get("dsh_slot").is_none() && minted.get("bridge").is_none());
|
||||
assert_eq!(minted["entry"], "client.js");
|
||||
for query in [
|
||||
"slot=workpane.panel",
|
||||
"slot=sidebar.section",
|
||||
"slot=customize.plugins.detail&dsh_slot=settings.section",
|
||||
] {
|
||||
let (status, body) = ui(&client, &base, &name, query).await?;
|
||||
assert_eq!(
|
||||
refusal(status, &body),
|
||||
(404, "plugin_ui_seat_not_admitted".to_string()),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
|
||||
let revision = minted["revision"].as_str().unwrap().to_string();
|
||||
let asset = format!("{}client.js", minted["base"].as_str().unwrap());
|
||||
let served = client
|
||||
.get(&asset)
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(served.status(), StatusCode::OK);
|
||||
let unlisted = client
|
||||
.get(format!("{}nothing.js", minted["base"].as_str().unwrap()))
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(unlisted.status(), StatusCode::NOT_FOUND);
|
||||
let body: serde_json::Value = unlisted.json().await?;
|
||||
assert_eq!(body["error"]["code"], "plugin_ui_file_not_reviewed");
|
||||
let routed = client
|
||||
.post(format!("{base}/{name}/routes/x"))
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(routed.status(), StatusCode::FORBIDDEN);
|
||||
let body: serde_json::Value = routed.json().await?;
|
||||
assert_eq!(body["error"]["code"], "plugin_route_not_admitted");
|
||||
|
||||
let revoked = client.post(format!("{base}/{name}/revoke")).send().await?;
|
||||
assert_eq!(revoked.status(), StatusCode::OK);
|
||||
let after = client
|
||||
.get(&asset)
|
||||
.header("If-Match", &revision)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(after.status(), StatusCode::PRECONDITION_FAILED);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -635,7 +635,7 @@ async fn snapshot_in_owner(
|
||||
snapshot_in_runtime(&state.runtime_threads, &state.sessions_dir, id).await
|
||||
}
|
||||
|
||||
async fn snapshot_in_runtime(
|
||||
pub(super) async fn snapshot_in_runtime(
|
||||
runtime: &Arc<RuntimeThreadManager>,
|
||||
sessions_dir: &Path,
|
||||
id: String,
|
||||
|
||||
@@ -18,111 +18,14 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
use tokio::io::{AsyncBufRead, AsyncWrite};
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FrameRefusal {
|
||||
#[error("control frame exceeds the 8 MiB transport limit")]
|
||||
TooLarge,
|
||||
#[error("control frame is not UTF-8")]
|
||||
InvalidUtf8,
|
||||
}
|
||||
pub use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, is_frame_refusal};
|
||||
|
||||
pub fn is_frame_refusal(error: &std::io::Error) -> bool {
|
||||
error
|
||||
.get_ref()
|
||||
.is_some_and(|source| source.is::<FrameRefusal>())
|
||||
}
|
||||
|
||||
/// Cancellation-safe, allocation-bounded newline framing shared by every
|
||||
/// local control transport. Consumed partial bytes remain owned by the reader.
|
||||
pub struct BoundedLines<R> {
|
||||
reader: R,
|
||||
pub(crate) pending: Vec<u8>,
|
||||
discarding: bool,
|
||||
}
|
||||
|
||||
impl<R: AsyncBufRead + Unpin> BoundedLines<R> {
|
||||
pub fn new(reader: R) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
pending: Vec::new(),
|
||||
discarding: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> R {
|
||||
self.reader
|
||||
}
|
||||
pub async fn next_line(&mut self) -> std::io::Result<Option<String>> {
|
||||
loop {
|
||||
let available = self.reader.fill_buf().await?;
|
||||
if available.is_empty() {
|
||||
self.discarding = false;
|
||||
if self.pending.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES {
|
||||
self.pending.clear();
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
FrameRefusal::TooLarge,
|
||||
));
|
||||
}
|
||||
let bytes = std::mem::take(&mut self.pending);
|
||||
return String::from_utf8(bytes).map(Some).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
|
||||
});
|
||||
}
|
||||
let available = &available[..available.len().min(8192)];
|
||||
let newline = available.iter().position(|&byte| byte == b'\n');
|
||||
let count = newline.unwrap_or(available.len());
|
||||
if self.discarding {
|
||||
self.reader.consume(count + usize::from(newline.is_some()));
|
||||
if newline.is_some() {
|
||||
self.discarding = false;
|
||||
} else {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if count > (MAX_RUNTIME_IMAGE_BODY_BYTES + 1).saturating_sub(self.pending.len()) {
|
||||
self.pending.clear();
|
||||
self.reader.consume(count + usize::from(newline.is_some()));
|
||||
self.discarding = newline.is_none();
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
FrameRefusal::TooLarge,
|
||||
));
|
||||
}
|
||||
// Do not let Vec's geometric growth double an almost-full frame.
|
||||
self.pending.reserve_exact(count);
|
||||
self.pending.extend_from_slice(&available[..count]);
|
||||
self.reader.consume(count + usize::from(newline.is_some()));
|
||||
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES
|
||||
&& self.pending.last() != Some(&b'\r')
|
||||
{
|
||||
self.pending.clear();
|
||||
self.discarding = newline.is_none();
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
FrameRefusal::TooLarge,
|
||||
));
|
||||
}
|
||||
if newline.is_some() {
|
||||
if self.pending.last() == Some(&b'\r') {
|
||||
self.pending.pop();
|
||||
}
|
||||
let bytes = std::mem::take(&mut self.pending);
|
||||
return String::from_utf8(bytes).map(Some).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const _: () =
|
||||
assert!(codewhale_util::bounded_lines::MAX_FRAME_BYTES == MAX_RUNTIME_IMAGE_BODY_BYTES);
|
||||
|
||||
/// The stdio JSON-RPC loop, generic over its transport so it can be driven by
|
||||
/// a duplex pipe in tests rather than the process's real stdin/stdout.
|
||||
|
||||
@@ -31,37 +31,6 @@ async fn control_framing_accepts_exact_limit_crlf_and_preserves_next_frame() {
|
||||
assert!(reader.next_line().await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_framing_refuses_oversize_before_retaining_extra_bytes() {
|
||||
let input = vec![b'x'; MAX_RUNTIME_IMAGE_BODY_BYTES + 4096];
|
||||
let mut reader = BoundedLines::new(BufReader::with_capacity(8192, input.as_slice()));
|
||||
assert_eq!(
|
||||
reader.next_line().await.unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
assert!(reader.pending.len() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
|
||||
assert!(reader.pending.capacity() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_framing_keeps_consumed_prefix_when_read_future_is_cancelled() {
|
||||
let (mut input, output) = tokio::io::duplex(64);
|
||||
input.write_all(b"partial").await.unwrap();
|
||||
let mut reader = BoundedLines::new(BufReader::new(output));
|
||||
std::future::poll_fn(|cx| {
|
||||
let mut read = std::pin::pin!(reader.next_line());
|
||||
assert!(std::future::Future::poll(read.as_mut(), cx).is_pending());
|
||||
std::task::Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(reader.pending, b"partial");
|
||||
input.write_all(b"-continued\n").await.unwrap();
|
||||
assert_eq!(
|
||||
reader.next_line().await.unwrap().as_deref(),
|
||||
Some("partial-continued")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_framing_rejects_non_utf8_and_keeps_eof_line_contract() {
|
||||
let mut invalid = BoundedLines::new(BufReader::new(&b"\xff\n"[..]));
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
mod acp_support;
|
||||
#[path = "../../engine/tests/support/acp_conformance.rs"]
|
||||
mod conformance;
|
||||
|
||||
use acp_support::{Client, fixture_config, text_of, updates};
|
||||
use agent_client_protocol_schema::ProtocolVersion;
|
||||
use agent_client_protocol_schema::v1 as acp;
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_app_server::acp_server::capture_frontend;
|
||||
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
|
||||
use codewhale_engine::runtime_threads::manager::{
|
||||
RuntimeThreadManager, RuntimeThreadManagerConfig,
|
||||
};
|
||||
use conformance::{Allowed, Conformance, Side};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
|
||||
const KNOWN: &[Allowed] = &[
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "initialize response",
|
||||
problem: "field `.agentCapabilities.modelSelection` is not in the official type",
|
||||
reason: "Codewhale extension flag; official AgentCapabilities has no modelSelection",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/new response",
|
||||
problem: "field `.models` is not in the official type",
|
||||
reason: "pre-configOptions model selector; official NewSessionResponse has no models",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/load response",
|
||||
problem: "field `.models` is not in the official type",
|
||||
reason: "pre-configOptions model selector; official LoadSessionResponse has no models",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/load response",
|
||||
problem: "field `.sessionId` is not in the official type",
|
||||
reason: "session/load reuses the session/new body; official LoadSessionResponse has no sessionId",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/list response",
|
||||
problem: "field `.sessions[].createdAt` is not in the official type",
|
||||
reason: "official SessionInfo carries updatedAt only",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/list response",
|
||||
problem: "field `.sessions[].messageCount` is not in the official type",
|
||||
reason: "official SessionInfo has no message count",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "response",
|
||||
problem: "answers no outstanding request (id null)",
|
||||
reason: "an unknown notification is answered with an id-null -32601 error; JSON-RPC forbids replying to a notification",
|
||||
},
|
||||
];
|
||||
|
||||
fn wire<T: Serialize>(value: T) -> Value {
|
||||
serde_json::to_value(value).expect("official type serializes")
|
||||
}
|
||||
|
||||
async fn prompt_to_end(
|
||||
client: &mut Client,
|
||||
params: Value,
|
||||
answer: Value,
|
||||
) -> Result<(Value, Vec<Value>)> {
|
||||
client.next_id += 1;
|
||||
let id = client.next_id;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":id,"method":"session/prompt","params":params}))
|
||||
.await?;
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let frame = client.recv().await?;
|
||||
if frame["id"] == json!(id) && frame.get("method").is_none() {
|
||||
return Ok((frame, frames));
|
||||
}
|
||||
if frame["method"] == "session/request_permission" {
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":frame["id"],"result":answer}))
|
||||
.await?;
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_frame_the_acp_server_writes_is_an_official_v1_shape() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(64 << 20)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(120), journey())
|
||||
.await
|
||||
.map_err(|_| anyhow!("conformance journey exceeded 120s"))?
|
||||
})
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| anyhow!("conformance journey thread panicked"))?
|
||||
}
|
||||
|
||||
async fn journey() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
|
||||
let workspace = dir.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
let runtime_dir = dir.path().join("runtime");
|
||||
let config = fixture_config();
|
||||
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
|
||||
let manager = Arc::new(RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace.clone(),
|
||||
RuntimeThreadManagerConfig {
|
||||
mcp_auth_context: Default::default(),
|
||||
data_dir: runtime_dir.clone(),
|
||||
task_data_dir: runtime_dir,
|
||||
sessions_dir: None,
|
||||
max_active_threads: 4,
|
||||
},
|
||||
plugins,
|
||||
codewhale_engine::core::engine::EngineHostProfile::Acp,
|
||||
None,
|
||||
None,
|
||||
)?);
|
||||
let provider = Arc::new(MockLlmClient::new(vec![
|
||||
canned::tool_call_turn(
|
||||
"plan-call",
|
||||
"todo_write",
|
||||
r#"{"todos":[{"content":"write the file","status":"in_progress"}]}"#,
|
||||
),
|
||||
canned::tool_call_turn(
|
||||
"write-call",
|
||||
"write",
|
||||
r#"{"path":"allowed.txt","content":"approved"}"#,
|
||||
),
|
||||
canned::simple_text_turn("settled after approval"),
|
||||
canned::tool_call_turn(
|
||||
"cancel-call",
|
||||
"write",
|
||||
r#"{"path":"cancelled.txt","content":"never"}"#,
|
||||
),
|
||||
canned::simple_text_turn("unused"),
|
||||
]));
|
||||
manager.set_test_model_client(provider.clone());
|
||||
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
|
||||
let frontend = capture_frontend(
|
||||
config,
|
||||
"fixture-model".into(),
|
||||
workspace.clone(),
|
||||
Arc::clone(&manager),
|
||||
sessions_dir,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let mut observed = Conformance::checking(&[Side::Agent]);
|
||||
|
||||
let (mut client, agent_read, agent_write) = Client::connect(true);
|
||||
let first_connection =
|
||||
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let first_script = async {
|
||||
let result = async {
|
||||
let (initialized, _) = client
|
||||
.request(
|
||||
"initialize",
|
||||
wire(
|
||||
acp::InitializeRequest::new(ProtocolVersion::V1)
|
||||
.client_capabilities(
|
||||
acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new())
|
||||
.terminal(false),
|
||||
)
|
||||
.client_info(acp::Implementation::new("conformance", "1.0.0")),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(initialized["result"]["protocolVersion"], 1);
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","method":"$/cancel_request","params":wire(acp::CancelRequestNotification::new(999_i64))}))
|
||||
.await?;
|
||||
let (unknown, _) = client.request("session/not_a_method", json!({})).await?;
|
||||
assert_eq!(unknown["error"]["code"], -32601);
|
||||
let (invalid, _) = client
|
||||
.request("session/set_mode", json!({"modeId":"agent"}))
|
||||
.await?;
|
||||
assert_eq!(invalid["error"]["code"], -32602);
|
||||
let (relative, _) = client
|
||||
.request("session/new", wire(acp::NewSessionRequest::new("relative/dir")))
|
||||
.await?;
|
||||
assert_eq!(relative["error"]["code"], -32602);
|
||||
let (created, _) = client
|
||||
.request("session/new", wire(acp::NewSessionRequest::new(&workspace)))
|
||||
.await?;
|
||||
let session = created["result"]["sessionId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
|
||||
.to_string();
|
||||
let (mode, _) = client
|
||||
.request(
|
||||
"session/set_mode",
|
||||
wire(acp::SetSessionModeRequest::new(session.clone(), "agent")),
|
||||
)
|
||||
.await?;
|
||||
assert!(mode.get("result").is_some(), "{mode}");
|
||||
let (option, _) = client
|
||||
.request(
|
||||
"session/set_config_option",
|
||||
wire(acp::SetSessionConfigOptionRequest::new(
|
||||
session.clone(),
|
||||
"model",
|
||||
"fixture-model",
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
assert!(option.get("result").is_some(), "{option}");
|
||||
let (image, _) = client
|
||||
.request(
|
||||
"session/prompt",
|
||||
wire(acp::PromptRequest::new(
|
||||
session.clone(),
|
||||
vec![acp::ContentBlock::Image(acp::ImageContent::new(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
))],
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(image["error"]["code"], -32602);
|
||||
let allow = wire(acp::RequestPermissionResponse::new(
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
"allow-once",
|
||||
)),
|
||||
));
|
||||
let (done, frames) = prompt_to_end(
|
||||
&mut client,
|
||||
wire(acp::PromptRequest::new(
|
||||
session.clone(),
|
||||
vec![acp::ContentBlock::Text(acp::TextContent::new(
|
||||
"plan, then write under Ask",
|
||||
))],
|
||||
)),
|
||||
allow,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(done["result"]["stopReason"], "end_turn");
|
||||
assert!(!updates(&frames, "tool_call").is_empty());
|
||||
assert_eq!(text_of(&frames, "agent_message_chunk"), "settled after approval");
|
||||
|
||||
client.next_id += 1;
|
||||
let cancel_id = client.next_id;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":wire(acp::PromptRequest::new(
|
||||
session.clone(),
|
||||
vec![acp::ContentBlock::Text(acp::TextContent::new("write then cancel"))],
|
||||
))}))
|
||||
.await?;
|
||||
let (permission, _) = client.until_permission().await?;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":wire(acp::CancelNotification::new(session.clone()))}))
|
||||
.await?;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":permission["id"],"result":wire(acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Cancelled))}))
|
||||
.await?;
|
||||
let (cancelled, _) = client.until_response(cancel_id).await?;
|
||||
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
|
||||
let (listed, _) = client
|
||||
.request(
|
||||
"session/list",
|
||||
wire(acp::ListSessionsRequest::new().cwd(workspace.clone())),
|
||||
)
|
||||
.await?;
|
||||
assert!(listed["result"]["sessions"].is_array());
|
||||
Ok::<_, anyhow::Error>(session)
|
||||
}
|
||||
.await;
|
||||
let _ = client.write.shutdown().await;
|
||||
result
|
||||
};
|
||||
let (served, session) = tokio::join!(first_connection, first_script);
|
||||
served?;
|
||||
let session = session?;
|
||||
for (sent, frame) in client.log.take().unwrap_or_default() {
|
||||
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
|
||||
}
|
||||
|
||||
let (mut client, agent_read, agent_write) = Client::connect(true);
|
||||
let second_connection =
|
||||
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let second_script = async {
|
||||
let result = async {
|
||||
client
|
||||
.request(
|
||||
"initialize",
|
||||
wire(acp::InitializeRequest::new(ProtocolVersion::V1)),
|
||||
)
|
||||
.await?;
|
||||
let (missing, _) = client
|
||||
.request(
|
||||
"session/load",
|
||||
wire(acp::LoadSessionRequest::new("no-such-session", &workspace)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(missing["error"]["code"], -32602);
|
||||
let (loaded, replay) = client
|
||||
.request(
|
||||
"session/load",
|
||||
wire(acp::LoadSessionRequest::new(session.clone(), &workspace)),
|
||||
)
|
||||
.await?;
|
||||
assert!(loaded.get("result").is_some(), "{loaded}");
|
||||
assert!(text_of(&replay, "user_message_chunk").contains("plan, then write under Ask"));
|
||||
assert!(!updates(&replay, "tool_call_update").is_empty());
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
let _ = client.write.shutdown().await;
|
||||
result
|
||||
};
|
||||
let (served, script) = tokio::join!(second_connection, second_script);
|
||||
served?;
|
||||
script?;
|
||||
for (sent, frame) in client.log.take().unwrap_or_default() {
|
||||
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
|
||||
}
|
||||
manager.shutdown_and_wait().await?;
|
||||
|
||||
assert!(
|
||||
observed.frames() > 20,
|
||||
"only {} frames observed",
|
||||
observed.frames()
|
||||
);
|
||||
observed.assert_only(KNOWN);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
mod acp_support;
|
||||
|
||||
use acp_support::{Client, fixture_config, text_of, updates};
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_app_server::acp_server::capture_frontend;
|
||||
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
|
||||
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
|
||||
use codewhale_engine::runtime_threads::manager::{
|
||||
RuntimeThreadManager, RuntimeThreadManagerConfig,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
|
||||
#[test]
|
||||
fn editor_drives_a_session_through_permission_cancel_and_load() -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(64 << 20)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(120), journey())
|
||||
.await
|
||||
.map_err(|_| anyhow!("journey exceeded 120s"))?
|
||||
})
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| anyhow!("journey thread panicked"))?
|
||||
}
|
||||
|
||||
async fn journey() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
|
||||
let workspace = dir.path().join("workspace");
|
||||
std::fs::create_dir(&workspace)?;
|
||||
let runtime_dir = dir.path().join("runtime");
|
||||
let config = fixture_config();
|
||||
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
|
||||
let manager = Arc::new(RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace.clone(),
|
||||
RuntimeThreadManagerConfig {
|
||||
mcp_auth_context: Default::default(),
|
||||
data_dir: runtime_dir.clone(),
|
||||
task_data_dir: runtime_dir,
|
||||
sessions_dir: None,
|
||||
max_active_threads: 4,
|
||||
},
|
||||
plugins,
|
||||
codewhale_engine::core::engine::EngineHostProfile::Acp,
|
||||
None,
|
||||
None,
|
||||
)?);
|
||||
let provider = Arc::new(MockLlmClient::new(vec![
|
||||
canned::tool_call_turn(
|
||||
"provider-id-not-authority",
|
||||
"write",
|
||||
r#"{"path":"allowed.txt","content":"approved"}"#,
|
||||
),
|
||||
canned::simple_text_turn("settled after approval"),
|
||||
canned::tool_call_turn(
|
||||
"provider-id-two",
|
||||
"write",
|
||||
r#"{"path":"cancelled.txt","content":"never"}"#,
|
||||
),
|
||||
canned::simple_text_turn("resumed in the real session"),
|
||||
]));
|
||||
manager.set_test_model_client(provider.clone());
|
||||
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
|
||||
let frontend = capture_frontend(
|
||||
config,
|
||||
"fixture-model".into(),
|
||||
workspace.clone(),
|
||||
Arc::clone(&manager),
|
||||
sessions_dir,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let (mut client, agent_read, agent_write) = Client::connect(false);
|
||||
let first_connection =
|
||||
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let first_script = async {
|
||||
let result = async {
|
||||
let (initialized, _) = client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion":1,"clientCapabilities":{"terminal":false}}),
|
||||
)
|
||||
.await?;
|
||||
let capabilities = &initialized["result"]["agentCapabilities"];
|
||||
assert_eq!(initialized["result"]["protocolVersion"], 1);
|
||||
assert_eq!(capabilities["loadSession"], true);
|
||||
assert_eq!(capabilities["promptCapabilities"]["image"], false);
|
||||
assert_eq!(capabilities["promptCapabilities"]["audio"], false);
|
||||
assert_eq!(capabilities["mcpCapabilities"]["http"], false);
|
||||
assert_eq!(capabilities["mcpCapabilities"]["sse"], false);
|
||||
assert!(capabilities["sessionCapabilities"]["list"].is_object());
|
||||
assert!(capabilities["sessionCapabilities"].get("resume").is_none());
|
||||
let (relative, _) = client
|
||||
.request("session/new", json!({"cwd":"relative/dir"}))
|
||||
.await?;
|
||||
assert_eq!(relative["error"]["code"], -32602);
|
||||
let (mcp, _) = client
|
||||
.request(
|
||||
"session/new",
|
||||
json!({"cwd":workspace,"mcpServers":[{"name":"x","command":"x","args":[],"env":[]}]}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(mcp["error"]["code"], -32602);
|
||||
let (resume, _) = client
|
||||
.request("session/resume", json!({"sessionId":"x","cwd":workspace}))
|
||||
.await?;
|
||||
assert_eq!(resume["error"]["code"], -32601);
|
||||
let (created, _) = client
|
||||
.request("session/new", json!({"cwd":workspace}))
|
||||
.await?;
|
||||
let session = created["result"]["sessionId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
|
||||
.to_string();
|
||||
let (image, _) = client
|
||||
.request(
|
||||
"session/prompt",
|
||||
json!({"sessionId":session,"prompt":[
|
||||
{"type":"text","text":"look"},
|
||||
{"type":"image","data":"AAAA","mimeType":"image/png"}
|
||||
]}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(image["error"]["code"], -32602);
|
||||
assert_eq!(
|
||||
provider.call_count(),
|
||||
0,
|
||||
"a refused prompt never reaches the provider"
|
||||
);
|
||||
client.next_id += 1;
|
||||
let prompt_id = client.next_id;
|
||||
client
|
||||
.send(
|
||||
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
|
||||
"sessionId":session,"prompt":[{"type":"text","text":"write under Ask"}]}}),
|
||||
)
|
||||
.await?;
|
||||
let (permission, before) = client.until_permission().await?;
|
||||
assert_eq!(permission["params"]["sessionId"], session.as_str());
|
||||
assert_eq!(permission["params"]["toolCall"]["kind"], "edit");
|
||||
let kinds: Vec<_> = permission["params"]["options"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|option| option["kind"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(kinds, ["allow_once", "reject_once"]);
|
||||
assert!(
|
||||
!updates(&before, "tool_call").is_empty(),
|
||||
"the tool call is announced before approval is requested"
|
||||
);
|
||||
assert!(
|
||||
updates(&before, "tool_call_update")
|
||||
.iter()
|
||||
.all(|frame| frame.pointer("/params/update/status") != Some(&json!("in_progress"))),
|
||||
"nothing dispatches before the person decides"
|
||||
);
|
||||
assert!(!workspace.join("allowed.txt").exists());
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":permission["id"],
|
||||
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
|
||||
.await?;
|
||||
let (done, after) = client.until_response(prompt_id).await?;
|
||||
assert_eq!(done["result"]["stopReason"], "end_turn");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("allowed.txt"))?,
|
||||
"approved"
|
||||
);
|
||||
assert!(
|
||||
updates(&after, "tool_call_update")
|
||||
.iter()
|
||||
.any(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
|
||||
);
|
||||
assert_eq!(
|
||||
text_of(&after, "agent_message_chunk"),
|
||||
"settled after approval"
|
||||
);
|
||||
client.next_id += 1;
|
||||
let cancel_id = client.next_id;
|
||||
client
|
||||
.send(
|
||||
json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":{
|
||||
"sessionId":session,"prompt":"write then cancel"}}),
|
||||
)
|
||||
.await?;
|
||||
let (second, _) = client.until_permission().await?;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":session}}))
|
||||
.await?;
|
||||
client
|
||||
.send(json!({"jsonrpc":"2.0","id":second["id"],
|
||||
"result":{"outcome":{"outcome":"cancelled"}}}))
|
||||
.await?;
|
||||
let (cancelled, _) = client.until_response(cancel_id).await?;
|
||||
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
|
||||
assert!(!workspace.join("cancelled.txt").exists());
|
||||
let (listed, _) = client
|
||||
.request("session/list", json!({"cwd":workspace}))
|
||||
.await?;
|
||||
assert!(
|
||||
listed["result"]["sessions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| entry["sessionId"] == session.as_str())
|
||||
);
|
||||
client.request("shutdown", json!({})).await?;
|
||||
Ok::<_, anyhow::Error>(session)
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
let _ = client.write.shutdown().await;
|
||||
}
|
||||
result
|
||||
};
|
||||
let (served, session) = tokio::join!(first_connection, first_script);
|
||||
served?;
|
||||
let session = session?;
|
||||
|
||||
let thread = manager
|
||||
.list_threads(Default::default(), None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("no runtime thread was created"))?;
|
||||
let detail = manager.get_thread_detail(&thread.id).await?;
|
||||
assert!(
|
||||
detail.pending_approvals.is_empty(),
|
||||
"cancel settles the approval"
|
||||
);
|
||||
assert_eq!(detail.turns.len(), 2);
|
||||
assert_eq!(detail.turns[0].status, RuntimeTurnStatus::Completed);
|
||||
assert_eq!(detail.turns[1].status, RuntimeTurnStatus::Interrupted);
|
||||
|
||||
let (mut client, agent_read, agent_write) = Client::connect(false);
|
||||
let second_connection =
|
||||
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
|
||||
let second_script = async {
|
||||
client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion":1,"clientCapabilities":{}}),
|
||||
)
|
||||
.await?;
|
||||
let (missing, _) = client
|
||||
.request(
|
||||
"session/load",
|
||||
json!({"sessionId":"no-such-session","cwd":workspace,"mcpServers":[]}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(missing["error"]["code"], -32602);
|
||||
let (loaded, replay) = client
|
||||
.request(
|
||||
"session/load",
|
||||
json!({"sessionId":session,"cwd":workspace,"mcpServers":[]}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(loaded["result"]["sessionId"], session.as_str());
|
||||
assert!(text_of(&replay, "user_message_chunk").contains("write under Ask"));
|
||||
assert!(text_of(&replay, "agent_message_chunk").contains("settled after approval"));
|
||||
assert!(!updates(&replay, "tool_call").is_empty());
|
||||
let (resumed, frames) = client
|
||||
.request(
|
||||
"session/prompt",
|
||||
json!({"sessionId":session,"prompt":"continue"}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resumed["result"]["stopReason"], "end_turn");
|
||||
assert_eq!(
|
||||
text_of(&frames, "agent_message_chunk"),
|
||||
"resumed in the real session"
|
||||
);
|
||||
client.request("shutdown", json!({})).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
let (served, script) = tokio::join!(second_connection, second_script);
|
||||
served?;
|
||||
script?;
|
||||
|
||||
let requests = provider.captured_requests();
|
||||
let last = serde_json::to_string(requests.last().ok_or_else(|| anyhow!("no model request"))?)?;
|
||||
assert!(
|
||||
last.contains("write under Ask") && last.contains("settled after approval"),
|
||||
"the loaded session continues from its real history"
|
||||
);
|
||||
manager.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
|
||||
|
||||
pub const STEP: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn fixture_config() -> Config {
|
||||
let mut config = Config::default();
|
||||
config.provider = Some("acp-fixture".into());
|
||||
config.providers = Some(ProvidersConfig {
|
||||
custom: HashMap::from([(
|
||||
"acp-fixture".into(),
|
||||
ProviderConfig {
|
||||
kind: Some("openai-compatible".into()),
|
||||
base_url: Some("http://127.0.0.1:18181/v1".into()),
|
||||
model: Some("fixture-model".into()),
|
||||
api_key: Some("local-test-key".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
..Default::default()
|
||||
});
|
||||
config.snapshots = Some(SnapshotsConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
});
|
||||
config.set_feature("mcp", false).unwrap();
|
||||
config.set_feature("subagents", false).unwrap();
|
||||
config
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
pub lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
|
||||
pub write: WriteHalf<DuplexStream>,
|
||||
pub next_id: u64,
|
||||
pub log: Option<Vec<(bool, Value)>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn connect(record: bool) -> (Self, ReadHalf<DuplexStream>, WriteHalf<DuplexStream>) {
|
||||
let (client, agent) = tokio::io::duplex(1 << 20);
|
||||
let (agent_read, agent_write) = tokio::io::split(agent);
|
||||
let (client_read, client_write) = tokio::io::split(client);
|
||||
(
|
||||
Client {
|
||||
lines: BufReader::new(client_read).lines(),
|
||||
write: client_write,
|
||||
next_id: 0,
|
||||
log: record.then(Vec::new),
|
||||
},
|
||||
agent_read,
|
||||
agent_write,
|
||||
)
|
||||
}
|
||||
pub async fn send(&mut self, message: Value) -> Result<()> {
|
||||
let mut line = serde_json::to_vec(&message)?;
|
||||
line.push(b'\n');
|
||||
self.write.write_all(&line).await?;
|
||||
self.write.flush().await?;
|
||||
if let Some(log) = &mut self.log {
|
||||
log.push((true, message));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn recv(&mut self) -> Result<Value> {
|
||||
let line = tokio::time::timeout(STEP, self.lines.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
|
||||
.ok_or_else(|| anyhow!("agent closed the transport"))?;
|
||||
let frame: Value = serde_json::from_str(&line)?;
|
||||
if let Some(log) = &mut self.log {
|
||||
log.push((false, frame.clone()));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
pub async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
|
||||
.await?;
|
||||
self.until_response(id).await
|
||||
}
|
||||
pub async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let frame = self.recv().await?;
|
||||
if frame["method"] == "session/request_permission" {
|
||||
return Ok((frame, frames));
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
}
|
||||
pub async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let frame = self.recv().await?;
|
||||
if frame["id"] == json!(id) && frame.get("method").is_none() {
|
||||
return Ok((frame, frames));
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
|
||||
frames
|
||||
.iter()
|
||||
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn text_of(frames: &[Value], kind: &str) -> String {
|
||||
updates(frames, kind)
|
||||
.iter()
|
||||
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
|
||||
.collect()
|
||||
}
|
||||
@@ -265,17 +265,16 @@ Common forwarded flags:
|
||||
--output-format <FORMAT> Output format: text or stream-json
|
||||
--hooks Opt in to configured hooks (tool_call_before, shell_env)
|
||||
|
||||
Plain `codewhale exec` is a one-shot model response: one Engine turn with the
|
||||
same system prompt as every other run, and no tools. Use `--auto` for
|
||||
non-interactive agent-with-tools execution. Tools are offered only with
|
||||
`--auto`, `--yolo`, `--allowed-tools`, or when resuming a session; limits such
|
||||
as `--max-turns`, `--disallowed-tools` or `--sandbox`, and the output format,
|
||||
never add tools. A reply cut off at the provider's output limit is continued
|
||||
in the same turn, at most 8 model steps unless `--max-turns` says otherwise.
|
||||
Plain `codewhale exec` runs the agent with the normal tool surface under the
|
||||
configured sandbox and approval posture. Use `--no-tools` for one answer with
|
||||
no tools. `--auto` selects automatic approvals and Full Access by default. A
|
||||
reply cut off at the provider's output limit is continued in the same turn, at
|
||||
most 8 model steps unless `--max-turns` says otherwise.
|
||||
--auto defaults to Full Access: no shell sandbox and trust mode on. Use --sandbox workspace-write to keep the shell sandbox and file-tool workspace restrictions; stricter configured policies also apply. A denied sandbox retry requires --allow-sandbox-elevation or --sandbox danger-full-access.
|
||||
Any --sandbox other than danger-full-access (or an equivalent sandbox_mode in config) disables trust mode.
|
||||
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Plain answer-only text/--json runs are not saved unless compaction needs recovery.
|
||||
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Answer-only (--no-tools) text/--json runs are not saved unless compaction needs recovery.
|
||||
Put flags before the prompt. Put -- before a prompt starting with - or containing literal exec flags.
|
||||
Piped stdin is the prompt when no prompt argument is given, or is appended to it; with a prompt argument, stdin that sends nothing for 2 seconds is ignored with a note on stderr.
|
||||
")]
|
||||
Exec(TuiPassthroughArgs),
|
||||
/// Manage durable Agent fleet runs.
|
||||
|
||||
@@ -354,6 +354,18 @@
|
||||
"cache_read_per_m": 0.145
|
||||
},
|
||||
"reason": "Fireworks hosts DeepSeek V4 Pro at its own published rates, as in the reviewed table in crates/engine/src/pricing.rs; Models.dev lists a flat 1.2/1.2 rate with a 0.6 cache-read rate for this deployment."
|
||||
},
|
||||
{
|
||||
"provider": "google",
|
||||
"id": "gemini-4-argon",
|
||||
"max_output": 65536,
|
||||
"reason": "Google publishes 65,536 output tokens by default for synchronous requests (https://aistudio.google.com/docs/models/gemini-4-argon, read 2026-11-10); Models.dev lists 32768."
|
||||
},
|
||||
{
|
||||
"provider": "google",
|
||||
"id": "gemini-4-carbon",
|
||||
"max_output": 65536,
|
||||
"reason": "Google publishes 65,536 output tokens by default for synchronous requests (https://aistudio.google.com/docs/models/gemini-4-carbon, read 2026-11-10); Models.dev lists 32768."
|
||||
}
|
||||
],
|
||||
"reviewed": {
|
||||
@@ -395,7 +407,7 @@
|
||||
"reasoning"
|
||||
],
|
||||
"reasoning": true,
|
||||
"source": "https://models.dev/catalog.json anthropic/claude-fable-5-2, retrieved 2026-11-10",
|
||||
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
|
||||
"family": "Anthropic"
|
||||
},
|
||||
"claude-opus-5-5": {
|
||||
@@ -1176,6 +1188,9 @@
|
||||
"context_window": 1000000,
|
||||
"max_output": 131072
|
||||
},
|
||||
"qwen4-max-preview": {
|
||||
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
|
||||
},
|
||||
"step-3.5-flash": {
|
||||
"context_window": 256000,
|
||||
"modalities": [
|
||||
@@ -1629,14 +1644,16 @@
|
||||
"family": "Google"
|
||||
},
|
||||
"gemini-4-argon": {
|
||||
"source": "official vendor model docs, verified 2026-10-17; see source_receipts",
|
||||
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
|
||||
"context_window": 1048576,
|
||||
"max_output": 65536,
|
||||
"reasoning": true,
|
||||
"family": "Google"
|
||||
},
|
||||
"gemini-4-carbon": {
|
||||
"source": "https://models.dev/catalog.json google/gemini-4-carbon, retrieved 2026-11-10",
|
||||
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
|
||||
"context_window": 1048576,
|
||||
"max_output": 32768,
|
||||
"max_output": 65536,
|
||||
"reasoning": true,
|
||||
"family": "Google"
|
||||
},
|
||||
@@ -1874,53 +1891,38 @@
|
||||
"source": "preserved public presentation facts, not route availability"
|
||||
},
|
||||
"mistral-large-4": {
|
||||
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"mistral-large-4-0-8": {
|
||||
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"minimax-m3.1-flash": {
|
||||
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1000000,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "MiniMax"
|
||||
"family": "Minimax"
|
||||
},
|
||||
"kimi-k3.1-pro": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
"family": "Moonshot"
|
||||
},
|
||||
"kimi-k3.1-pro-highspeed": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
"family": "Moonshot"
|
||||
},
|
||||
"gpt-6.1-terra": {
|
||||
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1050000,
|
||||
"max_output": 128000,
|
||||
"reasoning": true,
|
||||
"family": "OpenAI"
|
||||
},
|
||||
"muse-ember-1.0": {
|
||||
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "Meta"
|
||||
"family": "OpenAi"
|
||||
}
|
||||
},
|
||||
"official_deepseek_aliases": {
|
||||
@@ -4178,6 +4180,12 @@
|
||||
"added_at": "2026-08-02",
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "qwen4-max-preview",
|
||||
"label": "Qwen",
|
||||
"added_at": "2026-11-10",
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "kimi-for-coding-highspeed",
|
||||
"label": "Moonshot/Kimi",
|
||||
@@ -4574,19 +4582,11 @@
|
||||
{
|
||||
"id": "mistral-large-4",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"added_at": "2026-11-05",
|
||||
"aliases": [
|
||||
"mistral-large-4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-4-0-8",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"aliases": [
|
||||
"mistral-large-4-0-8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M3.1-Flash",
|
||||
"label": "MiniMax",
|
||||
@@ -4618,14 +4618,6 @@
|
||||
"aliases": [
|
||||
"gpt-6.1-terra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "muse-ember-1.0",
|
||||
"label": "Meta",
|
||||
"added_at": "2026-11-09",
|
||||
"aliases": [
|
||||
"muse-ember-1.0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"constants": {
|
||||
@@ -4781,7 +4773,12 @@
|
||||
"https://developers.openai.com/api/docs/models/gpt-6-sol": "verified 2026-10-17: $2 in / $0.20 cached / $10 out / $2.50 cache write; gpt-6.1-sol $2 / $0.10 / $10 / $2.50 and gpt-6-luna $0.10 / $0.01 / $0.50 per the changelog",
|
||||
"https://developers.openai.com/api/docs/deprecations": "verified 2026-10-17: gpt-5.3-codex and gpt-5.1 move to gpt-6-sol and gpt-5.4-nano to gpt-6-luna, all shutting down 2027-04-01; the catalog has no lifecycle field, so the rows stay until shutdown",
|
||||
"https://ai.google.dev/gemini-api/docs/changelog": "verified 2026-10-17: gemini-4-argon GA 2026-10-13, gemini-3.8-flash GA 2026-09-02; gemini-3.7-flash (auto-routed to gemini-3.8-flash) and gemini-3.5-flash (auto-routed to gemini-3.6-flash) deprecated 2026-10-08 and kept only as selector aliases",
|
||||
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated but no output ceiling is, so none is recorded",
|
||||
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated; the output ceiling is recorded from its own model page (see the aistudio.google.com receipts, verified 2026-11-10)",
|
||||
"https://aistudio.google.com/docs/models/gemini-4-carbon": "verified 2026-11-10: model code gemini-4-carbon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high, function calling, structured outputs, implicit caching only; thought signatures are not mentioned",
|
||||
"https://aistudio.google.com/docs/models/gemini-4-argon": "verified 2026-11-10: model code gemini-4-argon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high",
|
||||
"https://aistudio.google.com/docs/pricing": "verified 2026-11-10: gemini-4-carbon is $2 in / $10 out / $0.10 cached through 2026-11-30 and $4 / $20 / $0.20 from 2026-12-01; the catalog carries one undated price, so the row shows the current rate",
|
||||
"https://platform.claude.com/docs/en/models/fable-5-2/overview": "verified 2026-11-10: claude-fable-5-2, 1M context, 128K output, adaptive thinking always on, $8 in / $40 out, text and image input",
|
||||
"https://platform.claude.com/docs/en/build-with-claude/effort": "verified 2026-11-10: xhigh is its own effort level on Fable 5.2, Mythos 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5, Sonnet 5 and Haiku 5.5; not every model that supports max supports xhigh",
|
||||
"https://docs.z.ai/guides/vlm/glm-5.3-flash": "verified 2026-10-17: model code glm-5.3-flash, 1M context, 128K output, thinking.type only supports enabled",
|
||||
"https://dev.meta.ai/docs/models.md": "verified 2026-10-17: muse-spark-1.3 is recommended for new work, muse-spark-1.2 is the previous version, muse-spark-1.3-contributor has no max effort, muse-wonder-1.0 is a preview; all 1,048,576 context",
|
||||
"https://dev.meta.ai/docs/pricing-rate-limits": "verified 2026-10-17: Muse Spark 1.3 and 1.2 $1.25 in / $0.15 cached / $4.25 out, contributor $0.10 / $0.002 / $0.20; Muse Wonder 1.0 $4 / $0.20 / $20; no long-context premium",
|
||||
@@ -4791,7 +4788,8 @@
|
||||
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
|
||||
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
|
||||
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
|
||||
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
|
||||
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
|
||||
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
|
||||
},
|
||||
"transports": [
|
||||
{
|
||||
@@ -5064,6 +5062,17 @@
|
||||
"default_for_provider": false,
|
||||
"source": "preserved reviewed exact transport snapshot; see source_receipts"
|
||||
},
|
||||
{
|
||||
"provider": "modelstudio-token-plan",
|
||||
"id": "qwen4-max-preview",
|
||||
"endpoint_key": "chat",
|
||||
"default_for_provider": false,
|
||||
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
|
||||
"limits": {},
|
||||
"capabilities": {
|
||||
"image_input": "supported"
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": "opencode-go",
|
||||
"id": "qwen3.8-flash",
|
||||
@@ -6882,10 +6891,22 @@
|
||||
"@MODELSTUDIO_TEXT_MODELS"
|
||||
],
|
||||
"modelstudio-coding-plan": [
|
||||
"@MODELSTUDIO_TEXT_MODELS"
|
||||
"qwen3.8-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen3.6-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash-0731",
|
||||
"glm-5.2"
|
||||
],
|
||||
"modelstudio-coding-plan-anthropic": [
|
||||
"@MODELSTUDIO_TEXT_MODELS"
|
||||
"qwen3.8-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen3.6-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash-0731",
|
||||
"glm-5.2"
|
||||
],
|
||||
"mistral": [
|
||||
"$DEFAULT_MISTRAL_MODEL",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
|
||||
"pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.",
|
||||
"generated_by": "python3 scripts/catalog_models_dev.py seed render from scripts/catalog/models_dev_seed.toml and scripts/catalog/models_dev_seed.lock.json. Do not edit this file by hand; see docs/CATALOG_REFRESH.md.",
|
||||
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T22:14:03Z sha256 2d85d334089340e30ae6d3c6bbbcd8c7591e27b954e8089494348bb59d1bd7af",
|
||||
"coverage": "23 providers, 137 provider model rows, 4 canonical model entries."
|
||||
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T21:14:00Z sha256 019e248c87ca384d63f16bfd2fde60f8e26b2c6fa1126927a1dac25fe88c9740",
|
||||
"coverage": "23 providers, 136 provider model rows, 4 canonical model entries."
|
||||
},
|
||||
"models": {
|
||||
"deepseek/deepseek-v4-pro-0813": {
|
||||
@@ -3348,71 +3348,6 @@
|
||||
"output": 20,
|
||||
"cache_read": 0.2
|
||||
}
|
||||
},
|
||||
"muse-ember-1.0": {
|
||||
"id": "muse-ember-1.0",
|
||||
"canonical_model_id": "meta/muse-ember-1.0",
|
||||
"name": "Muse Ember",
|
||||
"description": "Muse Ember is Meta's smallest and most efficient reasoning model, built for the work agents repeat all day: tool calls, coding subtasks, routing, extraction, and long-document parsing. It pairs with Muse Wonder or Muse Spark, which plan while Muse Ember handles the volume.",
|
||||
"family": "muse",
|
||||
"release_date": "2026-11-09",
|
||||
"last_updated": "2026-11-09",
|
||||
"attachment": true,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [
|
||||
{
|
||||
"type": "effort",
|
||||
"values": [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_call": true,
|
||||
"structured_output": true,
|
||||
"temperature": true,
|
||||
"open_weights": false,
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"pdf",
|
||||
"audio"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"context": 1048576,
|
||||
"output": 524288
|
||||
},
|
||||
"cost": {
|
||||
"input": 0.1,
|
||||
"output": 0.5,
|
||||
"cache_read": 0.004,
|
||||
"tiers": [
|
||||
{
|
||||
"input": 0.3,
|
||||
"output": 1.5,
|
||||
"cache_read": 0.012,
|
||||
"tier": {
|
||||
"type": "context",
|
||||
"size": 200000
|
||||
}
|
||||
}
|
||||
],
|
||||
"context_over_200k": {
|
||||
"input": 0.3,
|
||||
"output": 1.5,
|
||||
"cache_read": 0.012
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5177,9 +5112,9 @@
|
||||
"output": 384000
|
||||
},
|
||||
"cost": {
|
||||
"input": 0.894012,
|
||||
"output": 1.788024,
|
||||
"cache_read": 0.074501
|
||||
"input": 0.95526,
|
||||
"output": 1.91052,
|
||||
"cache_read": 0.079605
|
||||
}
|
||||
},
|
||||
"deepseek/deepseek-v4-flash": {
|
||||
@@ -8084,7 +8019,7 @@
|
||||
"reasoning"
|
||||
],
|
||||
"reasoning": true,
|
||||
"source": "https://models.dev/catalog.json anthropic/claude-fable-5-2, retrieved 2026-11-10",
|
||||
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
|
||||
"family": "Anthropic"
|
||||
},
|
||||
"claude-opus-5-5": {
|
||||
@@ -8865,6 +8800,9 @@
|
||||
"context_window": 1000000,
|
||||
"max_output": 131072
|
||||
},
|
||||
"qwen4-max-preview": {
|
||||
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
|
||||
},
|
||||
"step-3.5-flash": {
|
||||
"context_window": 256000,
|
||||
"modalities": [
|
||||
@@ -9318,14 +9256,16 @@
|
||||
"family": "Google"
|
||||
},
|
||||
"gemini-4-argon": {
|
||||
"source": "official vendor model docs, verified 2026-10-17; see source_receipts",
|
||||
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
|
||||
"context_window": 1048576,
|
||||
"max_output": 65536,
|
||||
"reasoning": true,
|
||||
"family": "Google"
|
||||
},
|
||||
"gemini-4-carbon": {
|
||||
"source": "https://models.dev/catalog.json google/gemini-4-carbon, retrieved 2026-11-10",
|
||||
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
|
||||
"context_window": 1048576,
|
||||
"max_output": 32768,
|
||||
"max_output": 65536,
|
||||
"reasoning": true,
|
||||
"family": "Google"
|
||||
},
|
||||
@@ -9563,53 +9503,38 @@
|
||||
"source": "preserved public presentation facts, not route availability"
|
||||
},
|
||||
"mistral-large-4": {
|
||||
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"mistral-large-4-0-8": {
|
||||
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 262144,
|
||||
"reasoning": true,
|
||||
"family": "Mistral"
|
||||
},
|
||||
"minimax-m3.1-flash": {
|
||||
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1000000,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "MiniMax"
|
||||
"family": "Minimax"
|
||||
},
|
||||
"kimi-k3.1-pro": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
"family": "Moonshot"
|
||||
},
|
||||
"kimi-k3.1-pro-highspeed": {
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 1048576,
|
||||
"reasoning": true,
|
||||
"family": "Moonshot/Kimi"
|
||||
"family": "Moonshot"
|
||||
},
|
||||
"gpt-6.1-terra": {
|
||||
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
|
||||
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-11; unreviewed",
|
||||
"context_window": 1050000,
|
||||
"max_output": 128000,
|
||||
"reasoning": true,
|
||||
"family": "OpenAI"
|
||||
},
|
||||
"muse-ember-1.0": {
|
||||
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
|
||||
"context_window": 1048576,
|
||||
"max_output": 524288,
|
||||
"reasoning": true,
|
||||
"family": "Meta"
|
||||
"family": "OpenAi"
|
||||
}
|
||||
},
|
||||
"official_deepseek_aliases": {
|
||||
@@ -11867,6 +11792,12 @@
|
||||
"added_at": "2026-08-02",
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "qwen4-max-preview",
|
||||
"label": "Qwen",
|
||||
"added_at": "2026-11-10",
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "kimi-for-coding-highspeed",
|
||||
"label": "Moonshot/Kimi",
|
||||
@@ -12263,19 +12194,11 @@
|
||||
{
|
||||
"id": "mistral-large-4",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"added_at": "2026-11-05",
|
||||
"aliases": [
|
||||
"mistral-large-4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-4-0-8",
|
||||
"label": "Mistral",
|
||||
"added_at": "2026-10-06",
|
||||
"aliases": [
|
||||
"mistral-large-4-0-8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M3.1-Flash",
|
||||
"label": "MiniMax",
|
||||
@@ -12307,14 +12230,6 @@
|
||||
"aliases": [
|
||||
"gpt-6.1-terra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "muse-ember-1.0",
|
||||
"label": "Meta",
|
||||
"added_at": "2026-11-09",
|
||||
"aliases": [
|
||||
"muse-ember-1.0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"constants": {
|
||||
@@ -12470,7 +12385,12 @@
|
||||
"https://developers.openai.com/api/docs/models/gpt-6-sol": "verified 2026-10-17: $2 in / $0.20 cached / $10 out / $2.50 cache write; gpt-6.1-sol $2 / $0.10 / $10 / $2.50 and gpt-6-luna $0.10 / $0.01 / $0.50 per the changelog",
|
||||
"https://developers.openai.com/api/docs/deprecations": "verified 2026-10-17: gpt-5.3-codex and gpt-5.1 move to gpt-6-sol and gpt-5.4-nano to gpt-6-luna, all shutting down 2027-04-01; the catalog has no lifecycle field, so the rows stay until shutdown",
|
||||
"https://ai.google.dev/gemini-api/docs/changelog": "verified 2026-10-17: gemini-4-argon GA 2026-10-13, gemini-3.8-flash GA 2026-09-02; gemini-3.7-flash (auto-routed to gemini-3.8-flash) and gemini-3.5-flash (auto-routed to gemini-3.6-flash) deprecated 2026-10-08 and kept only as selector aliases",
|
||||
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated but no output ceiling is, so none is recorded",
|
||||
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated; the output ceiling is recorded from its own model page (see the aistudio.google.com receipts, verified 2026-11-10)",
|
||||
"https://aistudio.google.com/docs/models/gemini-4-carbon": "verified 2026-11-10: model code gemini-4-carbon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high, function calling, structured outputs, implicit caching only; thought signatures are not mentioned",
|
||||
"https://aistudio.google.com/docs/models/gemini-4-argon": "verified 2026-11-10: model code gemini-4-argon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high",
|
||||
"https://aistudio.google.com/docs/pricing": "verified 2026-11-10: gemini-4-carbon is $2 in / $10 out / $0.10 cached through 2026-11-30 and $4 / $20 / $0.20 from 2026-12-01; the catalog carries one undated price, so the row shows the current rate",
|
||||
"https://platform.claude.com/docs/en/models/fable-5-2/overview": "verified 2026-11-10: claude-fable-5-2, 1M context, 128K output, adaptive thinking always on, $8 in / $40 out, text and image input",
|
||||
"https://platform.claude.com/docs/en/build-with-claude/effort": "verified 2026-11-10: xhigh is its own effort level on Fable 5.2, Mythos 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5, Sonnet 5 and Haiku 5.5; not every model that supports max supports xhigh",
|
||||
"https://docs.z.ai/guides/vlm/glm-5.3-flash": "verified 2026-10-17: model code glm-5.3-flash, 1M context, 128K output, thinking.type only supports enabled",
|
||||
"https://dev.meta.ai/docs/models.md": "verified 2026-10-17: muse-spark-1.3 is recommended for new work, muse-spark-1.2 is the previous version, muse-spark-1.3-contributor has no max effort, muse-wonder-1.0 is a preview; all 1,048,576 context",
|
||||
"https://dev.meta.ai/docs/pricing-rate-limits": "verified 2026-10-17: Muse Spark 1.3 and 1.2 $1.25 in / $0.15 cached / $4.25 out, contributor $0.10 / $0.002 / $0.20; Muse Wonder 1.0 $4 / $0.20 / $20; no long-context premium",
|
||||
@@ -12480,7 +12400,8 @@
|
||||
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
|
||||
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
|
||||
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
|
||||
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
|
||||
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
|
||||
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
|
||||
},
|
||||
"transports": [
|
||||
{
|
||||
@@ -12753,6 +12674,17 @@
|
||||
"default_for_provider": false,
|
||||
"source": "preserved reviewed exact transport snapshot; see source_receipts"
|
||||
},
|
||||
{
|
||||
"provider": "modelstudio-token-plan",
|
||||
"id": "qwen4-max-preview",
|
||||
"endpoint_key": "chat",
|
||||
"default_for_provider": false,
|
||||
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
|
||||
"limits": {},
|
||||
"capabilities": {
|
||||
"image_input": "supported"
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": "opencode-go",
|
||||
"id": "qwen3.8-flash",
|
||||
@@ -14571,10 +14503,22 @@
|
||||
"@MODELSTUDIO_TEXT_MODELS"
|
||||
],
|
||||
"modelstudio-coding-plan": [
|
||||
"@MODELSTUDIO_TEXT_MODELS"
|
||||
"qwen3.8-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen3.6-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash-0731",
|
||||
"glm-5.2"
|
||||
],
|
||||
"modelstudio-coding-plan-anthropic": [
|
||||
"@MODELSTUDIO_TEXT_MODELS"
|
||||
"qwen3.8-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen3.6-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash-0731",
|
||||
"glm-5.2"
|
||||
],
|
||||
"mistral": [
|
||||
"$DEFAULT_MISTRAL_MODEL",
|
||||
|
||||
@@ -614,7 +614,7 @@ mod tests {
|
||||
let reviewed = bundled_reviewed();
|
||||
reviewed.validate().expect("committed data");
|
||||
assert_eq!(reviewed.selections.len(), 188);
|
||||
assert_eq!(reviewed.transports.len(), 122);
|
||||
assert_eq!(reviewed.transports.len(), 123);
|
||||
let go = reviewed_transport("opencode-go", "gpt-5.6-luna").unwrap();
|
||||
assert_eq!(go.endpoint_key, "responses");
|
||||
assert_eq!(
|
||||
|
||||
@@ -1520,6 +1520,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
// Trust posture with a `/set` entry point but no row; surfaced where
|
||||
// sandboxing acts, not as a settings sentence.
|
||||
def("sandbox_mode", SettingKind::String, "", None),
|
||||
def("agents", SettingKind::String, "", None),
|
||||
// Route memory written by the pickers: per-provider defaults, enabled
|
||||
// chooser sets, and pinned routes. Structured values no row could edit.
|
||||
def("provider_models", SettingKind::String, "", None),
|
||||
|
||||
@@ -73,6 +73,7 @@ path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
http = "1.5"
|
||||
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
|
||||
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
|
||||
@@ -179,6 +180,7 @@ wiremock = { version = "0.6", optional = true }
|
||||
codewhale-build-support = { path = "../build-support", version = "0.10.3" }
|
||||
|
||||
[dev-dependencies]
|
||||
agent-client-protocol-schema.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3", features = ["schema"] }
|
||||
codewhale-skills = { path = "../skills", version = "0.10.3", features = ["test-support"] }
|
||||
|
||||
+278
-14
@@ -5452,14 +5452,14 @@ function requireDumper() {
|
||||
let hasFoldableLine = false;
|
||||
const shouldTrackWidth = lineWidth !== -1;
|
||||
let previousLineBreak = -1;
|
||||
let plain2 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
|
||||
let plain3 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
|
||||
if (singleLineOnly || forceQuotes) {
|
||||
for (i2 = 0; i2 < string.length; char >= 65536 ? i2 += 2 : i2++) {
|
||||
char = codePointAt(string, i2);
|
||||
if (!isPrintable(char)) {
|
||||
return STYLE_DOUBLE;
|
||||
}
|
||||
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
|
||||
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
|
||||
prevChar = char;
|
||||
}
|
||||
} else {
|
||||
@@ -5475,13 +5475,13 @@ function requireDumper() {
|
||||
} else if (!isPrintable(char)) {
|
||||
return STYLE_DOUBLE;
|
||||
}
|
||||
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
|
||||
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
|
||||
prevChar = char;
|
||||
}
|
||||
hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i2 - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " ");
|
||||
}
|
||||
if (!hasLineBreak && !hasFoldableLine) {
|
||||
if (plain2 && !forceQuotes && !testAmbiguousType(string)) {
|
||||
if (plain3 && !forceQuotes && !testAmbiguousType(string)) {
|
||||
return STYLE_PLAIN;
|
||||
}
|
||||
return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE;
|
||||
@@ -13553,11 +13553,23 @@ var init_composition = __esm({
|
||||
});
|
||||
|
||||
// src/protocol.generated.ts
|
||||
var MAGIC_ASCII;
|
||||
var MAGIC_ASCII, ErrorCode;
|
||||
var init_protocol_generated = __esm({
|
||||
"src/protocol.generated.ts"() {
|
||||
"use strict";
|
||||
MAGIC_ASCII = "CWX1";
|
||||
ErrorCode = {
|
||||
ParseError: -32700,
|
||||
InvalidRequest: -32600,
|
||||
MethodNotFound: -32601,
|
||||
InvalidParams: -32602,
|
||||
Internal: -32603,
|
||||
ExecutionFailed: -32e3,
|
||||
NotAvailable: -32001,
|
||||
Refused: -32002,
|
||||
Denied: -32003,
|
||||
Cancelled: -32800
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13573,10 +13585,88 @@ var init_protocol = __esm({
|
||||
});
|
||||
|
||||
// src/rpc.ts
|
||||
var RpcError;
|
||||
var init_rpc = __esm({
|
||||
"src/rpc.ts"() {
|
||||
"use strict";
|
||||
init_protocol();
|
||||
RpcError = class extends Error {
|
||||
constructor(code, message, data2) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.data = data2;
|
||||
this.name = "RpcError";
|
||||
}
|
||||
code;
|
||||
data;
|
||||
toWire() {
|
||||
return this.data === void 0 ? { code: this.code, message: this.message } : { code: this.code, message: this.message, data: this.data };
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/session-prompt.ts
|
||||
function checkpointCall(host, ctx, session) {
|
||||
const invocation = host.liveInvocation(ctx);
|
||||
const ticket = invocation?.ticket;
|
||||
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
|
||||
}
|
||||
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
|
||||
var init_session_prompt = __esm({
|
||||
"src/dsh/session-prompt.ts"() {
|
||||
"use strict";
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
|
||||
sessionsAdapter = {
|
||||
service: "sessions",
|
||||
define(host) {
|
||||
class SessionsService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "sessions");
|
||||
}
|
||||
async flush(session) {
|
||||
const ctx = this.ctx;
|
||||
const call = checkpointCall(host, ctx, session);
|
||||
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
|
||||
const owner = call.owner;
|
||||
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted;
|
||||
const run = async () => {
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
const result2 = await host.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
|
||||
return result2;
|
||||
};
|
||||
const result = call.pending.then(run);
|
||||
call.pending = result.then(() => void 0, () => void 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Object.freeze(SessionsService.prototype);
|
||||
return SessionsService;
|
||||
}
|
||||
};
|
||||
systemPromptAdapter = {
|
||||
service: "systemPrompt",
|
||||
define() {
|
||||
class SystemPromptService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "systemPrompt");
|
||||
}
|
||||
section() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
|
||||
}
|
||||
getSectionOrder() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
|
||||
}
|
||||
}
|
||||
Object.freeze(SystemPromptService.prototype);
|
||||
return SystemPromptService;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14208,11 +14298,12 @@ var init_lib3 = __esm({
|
||||
});
|
||||
|
||||
// src/dsh/dsh-message-compat.js
|
||||
var HarnessError, UserQuestionError;
|
||||
var PERSONA_PREFIX_SECTION, HarnessError, UserQuestionError;
|
||||
var init_dsh_message_compat = __esm({
|
||||
"src/dsh/dsh-message-compat.js"() {
|
||||
"use strict";
|
||||
init_lib3();
|
||||
PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
|
||||
HarnessError = class extends Error {
|
||||
code;
|
||||
constructor(message, code, options) {
|
||||
@@ -14232,6 +14323,177 @@ var init_dsh_message_compat = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/agents.ts
|
||||
function plain(value, allowed, what) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
|
||||
const result = /* @__PURE__ */ Object.create(null);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
|
||||
if (descriptor.value !== void 0) result[key] = descriptor.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function defineSubagents(host) {
|
||||
class SubagentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "subagents");
|
||||
}
|
||||
getProvider(name) {
|
||||
return name === "spawn" ? SPAWN_PROVIDER : void 0;
|
||||
}
|
||||
async startActivation(request) {
|
||||
const owner = host.ownerOf(this.ctx);
|
||||
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
|
||||
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
|
||||
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
|
||||
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
|
||||
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
|
||||
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
|
||||
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
|
||||
const childId = input.childId;
|
||||
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
|
||||
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
|
||||
const prompt = spawn.prompt.map((block) => {
|
||||
const item = plain(block, ["type", "text"], "prompt block");
|
||||
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
|
||||
return item.text;
|
||||
}).join("");
|
||||
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
|
||||
const invocation = host.liveInvocation(this.ctx);
|
||||
if (!invocation || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
|
||||
}
|
||||
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
|
||||
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
|
||||
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
|
||||
signal.throwIfAborted();
|
||||
let persona;
|
||||
const denied = /* @__PURE__ */ new Set();
|
||||
let open = true;
|
||||
const sealed = () => {
|
||||
if (!open) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
|
||||
};
|
||||
const child = Object.freeze({
|
||||
id: childId,
|
||||
ctx: Object.freeze({
|
||||
systemPrompt: Object.freeze({
|
||||
getSectionOrder(name) {
|
||||
sealed();
|
||||
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
|
||||
return PERSONA_ORDER;
|
||||
},
|
||||
section(section) {
|
||||
sealed();
|
||||
const value = plain(section, ["name", "order", "text"], "system prompt section");
|
||||
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
|
||||
}
|
||||
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
|
||||
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
|
||||
persona = value.text;
|
||||
return () => void 0;
|
||||
}
|
||||
}),
|
||||
tools: Object.freeze({
|
||||
restrict(filter) {
|
||||
sealed();
|
||||
const value = plain(filter, ["deny"], "tool filter");
|
||||
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
|
||||
for (const name of value.deny) denied.add(name);
|
||||
return () => void 0;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
const listeners = (this.ctx.root?.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx !== void 0 && host.ownerOf(hook.ctx) === owner).map((hook) => hook.callback);
|
||||
try {
|
||||
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
|
||||
} finally {
|
||||
open = false;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const call = invocation;
|
||||
const rpc = (method, params, abort) => host.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
|
||||
const started = await rpc("subagent/start", {
|
||||
prompt,
|
||||
...options.model === void 0 ? {} : { model: options.model },
|
||||
...persona === void 0 ? {} : { persona },
|
||||
denied_tools: [...denied],
|
||||
delivery
|
||||
}, signal);
|
||||
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
|
||||
}
|
||||
let result;
|
||||
let disposed;
|
||||
return Object.freeze({
|
||||
childId,
|
||||
messageId: started.message_id,
|
||||
get result() {
|
||||
result ??= rpc("subagent/result", {}, call.signal).then((value) => {
|
||||
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
|
||||
}
|
||||
return Object.freeze({
|
||||
stopReason: value.stop_reason,
|
||||
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
|
||||
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
},
|
||||
dispose() {
|
||||
disposed ??= rpc("subagent/dispose", {}, call.signal).then(() => void 0);
|
||||
return disposed;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.freeze(SubagentsShim.prototype);
|
||||
return SubagentsShim;
|
||||
}
|
||||
function defineAgents() {
|
||||
class AgentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "agents");
|
||||
}
|
||||
}
|
||||
Object.freeze(AgentsShim.prototype);
|
||||
return AgentsShim;
|
||||
}
|
||||
var SPAWN_PROVIDER, PERSONA_ORDER, DSH_AGENT_ADAPTERS;
|
||||
var init_agents = __esm({
|
||||
"src/dsh/agents.ts"() {
|
||||
"use strict";
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
init_dsh_message_compat();
|
||||
SPAWN_PROVIDER = Object.freeze({
|
||||
name: "spawn",
|
||||
inheritsParentContext: false,
|
||||
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
|
||||
});
|
||||
PERSONA_ORDER = 0;
|
||||
DSH_AGENT_ADAPTERS = Object.freeze([
|
||||
{ service: "subagents", define: defineSubagents },
|
||||
{ service: "agents", define: defineAgents }
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/adapters.ts
|
||||
var DSH_ADAPTERS;
|
||||
var init_adapters = __esm({
|
||||
"src/dsh/adapters.ts"() {
|
||||
"use strict";
|
||||
init_session_prompt();
|
||||
init_agents();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/dsh-tools-compat.js
|
||||
var init_dsh_tools_compat = __esm({
|
||||
"src/dsh/dsh-tools-compat.js"() {
|
||||
@@ -14313,11 +14575,12 @@ var init_commands = __esm({
|
||||
|
||||
// src/root.ts
|
||||
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
|
||||
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
|
||||
var REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ownerStorage;
|
||||
var init_root = __esm({
|
||||
"src/root.ts"() {
|
||||
"use strict";
|
||||
init_composition();
|
||||
init_adapters();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
init_dsh_message_compat();
|
||||
@@ -14339,6 +14602,7 @@ var init_root = __esm({
|
||||
init_commands();
|
||||
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
|
||||
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
|
||||
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
|
||||
ownerStorage = new AsyncLocalStorage2();
|
||||
}
|
||||
});
|
||||
@@ -14659,7 +14923,7 @@ var init_composition_inventory = __esm({
|
||||
import { createHash as createHash3 } from "node:crypto";
|
||||
import { resolve as resolve5, relative as relative3, dirname as dirname4, sep as sep3 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL5 } from "node:url";
|
||||
function plain(path) {
|
||||
function plain2(path) {
|
||||
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !/[\\:\u0000-\u001f\u007f]/u.test(path) && !path.startsWith("/") && path.split("/").every((p) => p !== "" && p !== "." && p !== "..");
|
||||
}
|
||||
function fail(message) {
|
||||
@@ -14699,7 +14963,7 @@ function containedPackageModule(name, documents, files) {
|
||||
entry = value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).some((k) => k.startsWith(".")) ? target(value[subpath]) : subpath === "." ? target(value) : void 0;
|
||||
} else if (subpath === ".") entry = typeof data2.main === "string" ? data2.main : "./index.js";
|
||||
else entry = subpath;
|
||||
if (typeof entry !== "string" || !entry.startsWith("./") || !plain(entry.slice(2))) return;
|
||||
if (typeof entry !== "string" || !entry.startsWith("./") || !plain2(entry.slice(2))) return;
|
||||
const path = `${packageRoot}/${entry.slice(2)}`;
|
||||
if (!/\.(mjs|js|mts)$/.test(path) || !SHA.test(files[path] ?? "")) return;
|
||||
return { name, path, sha256: files[path] };
|
||||
@@ -14712,20 +14976,20 @@ async function reviewAgentPresets(input) {
|
||||
const baseUrl = pathToFileURL5(`${base}${sep3}`).href;
|
||||
function key(path) {
|
||||
const inside = relative3(base, path).split(sep3).join("/");
|
||||
if (!plain(inside)) fail("path escapes the admitted source root");
|
||||
if (!plain2(inside)) fail("path escapes the admitted source root");
|
||||
return inside;
|
||||
}
|
||||
const documents = /* @__PURE__ */ new Map();
|
||||
let bytes = 0;
|
||||
for (const doc of input.documents) {
|
||||
if (!plain(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
|
||||
if (!plain2(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
|
||||
bytes += Buffer.byteLength(doc.source);
|
||||
if (Buffer.byteLength(doc.source) > 1024 * 1024 || bytes > 4 * 1024 * 1024) fail("documents exceed their bound");
|
||||
documents.set(doc.path, doc.source);
|
||||
}
|
||||
const directories = new Set(input.directories);
|
||||
for (const path of directories) if (!plain(path)) fail("invalid source directory");
|
||||
for (const path of Object.keys(files)) if (!plain(path) || !SHA.test(files[path])) fail("invalid source file receipt");
|
||||
for (const path of directories) if (!plain2(path)) fail("invalid source directory");
|
||||
for (const path of Object.keys(files)) if (!plain2(path) || !SHA.test(files[path])) fail("invalid source file receipt");
|
||||
const invalid = /* @__PURE__ */ new Set();
|
||||
const parsed = /* @__PURE__ */ new Map();
|
||||
const io = {
|
||||
@@ -14790,7 +15054,7 @@ async function reviewAgentPresets(input) {
|
||||
}
|
||||
if (config.roots !== void 0 && !Array.isArray(config.roots)) fail("roots must be a literal list");
|
||||
for (const root of config.roots ?? []) {
|
||||
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
|
||||
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain2(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
|
||||
if (!directories.has(root.path)) fail("configured preset root was not admitted");
|
||||
roots.push({ path: resolve5(base, root.path), trust: root.trust });
|
||||
}
|
||||
|
||||
+302
-207
@@ -7371,14 +7371,14 @@ function requireDumper() {
|
||||
let hasFoldableLine = false;
|
||||
const shouldTrackWidth = lineWidth !== -1;
|
||||
let previousLineBreak = -1;
|
||||
let plain = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
|
||||
let plain2 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
|
||||
if (singleLineOnly || forceQuotes) {
|
||||
for (i2 = 0; i2 < string.length; char >= 65536 ? i2 += 2 : i2++) {
|
||||
char = codePointAt(string, i2);
|
||||
if (!isPrintable(char)) {
|
||||
return STYLE_DOUBLE;
|
||||
}
|
||||
plain = plain && isPlainSafe(char, prevChar, inblock);
|
||||
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
|
||||
prevChar = char;
|
||||
}
|
||||
} else {
|
||||
@@ -7394,13 +7394,13 @@ function requireDumper() {
|
||||
} else if (!isPrintable(char)) {
|
||||
return STYLE_DOUBLE;
|
||||
}
|
||||
plain = plain && isPlainSafe(char, prevChar, inblock);
|
||||
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
|
||||
prevChar = char;
|
||||
}
|
||||
hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i2 - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " ");
|
||||
}
|
||||
if (!hasLineBreak && !hasFoldableLine) {
|
||||
if (plain && !forceQuotes && !testAmbiguousType(string)) {
|
||||
if (plain2 && !forceQuotes && !testAmbiguousType(string)) {
|
||||
return STYLE_PLAIN;
|
||||
}
|
||||
return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE;
|
||||
@@ -15899,6 +15899,7 @@ var init_protocol_generated = __esm({
|
||||
{ name: "subagent/start", direction: "host_to_core", request: true, params: "SubagentStartParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "subagent/result", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "subagent/dispose", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "dsh/broker", direction: "host_to_core", request: true, params: "DshBrokerParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
|
||||
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
|
||||
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
|
||||
@@ -15939,6 +15940,16 @@ var init_protocol_generated = __esm({
|
||||
required: { owner: { ref: "OwnerRef" } },
|
||||
optional: { entry: { ref: "EntryRef" } }
|
||||
},
|
||||
DshBrokerParams: {
|
||||
strict: true,
|
||||
required: { owner: { ref: "OwnerRef" }, ticket: "string", service: "string", method: "string", input: "json" },
|
||||
optional: {}
|
||||
},
|
||||
DshBrokerTicketWire: {
|
||||
strict: true,
|
||||
required: { service: "string", ticket: "string" },
|
||||
optional: {}
|
||||
},
|
||||
EmptyParams: {
|
||||
strict: true,
|
||||
required: {},
|
||||
@@ -16102,7 +16113,7 @@ var init_protocol_generated = __esm({
|
||||
ToolCallParams: {
|
||||
strict: false,
|
||||
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
|
||||
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
|
||||
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", broker_tickets: { items: { ref: "DshBrokerTicketWire" } }, caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
|
||||
},
|
||||
UnregisterParams: {
|
||||
strict: true,
|
||||
@@ -16425,6 +16436,244 @@ var init_rpc = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/session-prompt.ts
|
||||
function checkpointCall(host2, ctx, session) {
|
||||
const invocation = host2.liveInvocation(ctx);
|
||||
const ticket = invocation?.ticket;
|
||||
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
|
||||
}
|
||||
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
|
||||
var init_session_prompt = __esm({
|
||||
"src/dsh/session-prompt.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
|
||||
sessionsAdapter = {
|
||||
service: "sessions",
|
||||
define(host2) {
|
||||
class SessionsService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "sessions");
|
||||
}
|
||||
async flush(session) {
|
||||
const ctx = this.ctx;
|
||||
const call = checkpointCall(host2, ctx, session);
|
||||
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
|
||||
const owner = call.owner;
|
||||
const live = () => checkpointCall(host2, ctx, session) === call && !call.signal.aborted;
|
||||
const run = async () => {
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
const result2 = await host2.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
|
||||
return result2;
|
||||
};
|
||||
const result = call.pending.then(run);
|
||||
call.pending = result.then(() => void 0, () => void 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Object.freeze(SessionsService.prototype);
|
||||
return SessionsService;
|
||||
}
|
||||
};
|
||||
systemPromptAdapter = {
|
||||
service: "systemPrompt",
|
||||
define() {
|
||||
class SystemPromptService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "systemPrompt");
|
||||
}
|
||||
section() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
|
||||
}
|
||||
getSectionOrder() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
|
||||
}
|
||||
}
|
||||
Object.freeze(SystemPromptService.prototype);
|
||||
return SystemPromptService;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/agents.ts
|
||||
function plain(value, allowed, what) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
|
||||
const result = /* @__PURE__ */ Object.create(null);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
|
||||
if (descriptor.value !== void 0) result[key] = descriptor.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function defineSubagents(host2) {
|
||||
class SubagentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "subagents");
|
||||
}
|
||||
getProvider(name) {
|
||||
return name === "spawn" ? SPAWN_PROVIDER : void 0;
|
||||
}
|
||||
async startActivation(request) {
|
||||
const owner = host2.ownerOf(this.ctx);
|
||||
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
|
||||
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
|
||||
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
|
||||
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
|
||||
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
|
||||
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
|
||||
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
|
||||
const childId = input.childId;
|
||||
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
|
||||
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
|
||||
const prompt = spawn.prompt.map((block) => {
|
||||
const item = plain(block, ["type", "text"], "prompt block");
|
||||
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
|
||||
return item.text;
|
||||
}).join("");
|
||||
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
|
||||
const invocation = host2.liveInvocation(this.ctx);
|
||||
if (!invocation || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
|
||||
}
|
||||
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
|
||||
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
|
||||
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
|
||||
signal.throwIfAborted();
|
||||
let persona;
|
||||
const denied2 = /* @__PURE__ */ new Set();
|
||||
let open2 = true;
|
||||
const sealed = () => {
|
||||
if (!open2) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
|
||||
};
|
||||
const child = Object.freeze({
|
||||
id: childId,
|
||||
ctx: Object.freeze({
|
||||
systemPrompt: Object.freeze({
|
||||
getSectionOrder(name) {
|
||||
sealed();
|
||||
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
|
||||
return PERSONA_ORDER;
|
||||
},
|
||||
section(section) {
|
||||
sealed();
|
||||
const value = plain(section, ["name", "order", "text"], "system prompt section");
|
||||
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
|
||||
}
|
||||
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
|
||||
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
|
||||
persona = value.text;
|
||||
return () => void 0;
|
||||
}
|
||||
}),
|
||||
tools: Object.freeze({
|
||||
restrict(filter) {
|
||||
sealed();
|
||||
const value = plain(filter, ["deny"], "tool filter");
|
||||
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
|
||||
for (const name of value.deny) denied2.add(name);
|
||||
return () => void 0;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
const listeners = (this.ctx.root?.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx !== void 0 && host2.ownerOf(hook.ctx) === owner).map((hook) => hook.callback);
|
||||
try {
|
||||
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
|
||||
} finally {
|
||||
open2 = false;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const call = invocation;
|
||||
const rpc2 = (method, params, abort) => host2.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
|
||||
const started = await rpc2("subagent/start", {
|
||||
prompt,
|
||||
...options.model === void 0 ? {} : { model: options.model },
|
||||
...persona === void 0 ? {} : { persona },
|
||||
denied_tools: [...denied2],
|
||||
delivery
|
||||
}, signal);
|
||||
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
|
||||
}
|
||||
let result;
|
||||
let disposed;
|
||||
return Object.freeze({
|
||||
childId,
|
||||
messageId: started.message_id,
|
||||
get result() {
|
||||
result ??= rpc2("subagent/result", {}, call.signal).then((value) => {
|
||||
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
|
||||
}
|
||||
return Object.freeze({
|
||||
stopReason: value.stop_reason,
|
||||
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
|
||||
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
},
|
||||
dispose() {
|
||||
disposed ??= rpc2("subagent/dispose", {}, call.signal).then(() => void 0);
|
||||
return disposed;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.freeze(SubagentsShim.prototype);
|
||||
return SubagentsShim;
|
||||
}
|
||||
function defineAgents() {
|
||||
class AgentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "agents");
|
||||
}
|
||||
}
|
||||
Object.freeze(AgentsShim.prototype);
|
||||
return AgentsShim;
|
||||
}
|
||||
var SPAWN_PROVIDER, PERSONA_ORDER, DSH_AGENT_ADAPTERS;
|
||||
var init_agents = __esm({
|
||||
"src/dsh/agents.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
init_dsh_message_compat();
|
||||
SPAWN_PROVIDER = Object.freeze({
|
||||
name: "spawn",
|
||||
inheritsParentContext: false,
|
||||
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
|
||||
});
|
||||
PERSONA_ORDER = 0;
|
||||
DSH_AGENT_ADAPTERS = Object.freeze([
|
||||
{ service: "subagents", define: defineSubagents },
|
||||
{ service: "agents", define: defineAgents }
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/adapters.ts
|
||||
var DSH_ADAPTERS;
|
||||
var init_adapters = __esm({
|
||||
"src/dsh/adapters.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_session_prompt();
|
||||
init_agents();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
|
||||
}
|
||||
});
|
||||
|
||||
// src/shims/core.ts
|
||||
function failureFor(error) {
|
||||
if (error instanceof CoreCallError) return error;
|
||||
@@ -17300,12 +17549,13 @@ function pendingFibers(fiber) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
var OWNER, REFUSED_SERVICES, PROVIDED_SERVICES, ACTIVATE_DEADLINE_MS, DISPOSE_DEADLINE_MS, ownerStorage, HostRoot;
|
||||
var OWNER, REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ACTIVATE_DEADLINE_MS, DISPOSE_DEADLINE_MS, ownerStorage, HostRoot;
|
||||
var init_root = __esm({
|
||||
"src/root.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_composition();
|
||||
init_adapters();
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
@@ -17329,11 +17579,12 @@ var init_root = __esm({
|
||||
OWNER = /* @__PURE__ */ Symbol.for("codewhale.extension-host.owner");
|
||||
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
|
||||
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
|
||||
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
|
||||
ACTIVATE_DEADLINE_MS = 5e3;
|
||||
DISPOSE_DEADLINE_MS = 2e3;
|
||||
ownerStorage = new AsyncLocalStorage2();
|
||||
HostRoot = class {
|
||||
constructor(rpc2, tier) {
|
||||
constructor(rpc2, tier, adapters = DSH_ADAPTERS) {
|
||||
this.rpc = rpc2;
|
||||
this.tier = tier;
|
||||
const root = new Context();
|
||||
@@ -17471,28 +17722,6 @@ var init_root = __esm({
|
||||
return this.api().delete(key);
|
||||
}
|
||||
}
|
||||
class SessionsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "sessions");
|
||||
}
|
||||
async flush(session) {
|
||||
const invocation = host2.toolInvocations.getStore();
|
||||
const owner = this.ctx[OWNER];
|
||||
const live = () => invocation && invocation.active && !invocation.signal.aborted && invocation.owner === owner && owner?.state === "active" && !owner.disposing && invocation.ticket !== void 0 && session === invocation.session && session !== void 0;
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
|
||||
const call = invocation;
|
||||
const run = async () => {
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
const result2 = await host2.rpc.request("session/checkpoint", { owner: call.owner.ref, ticket: call.ticket }, call.signal);
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
|
||||
return result2;
|
||||
};
|
||||
const result = call.pending.then(run);
|
||||
call.pending = result.then(() => void 0, () => void 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
class UserQuestionsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "userQuestions");
|
||||
@@ -17611,185 +17840,49 @@ var init_root = __esm({
|
||||
return Promise.race([result, cancelled]).finally(() => signal.removeEventListener("abort", cancel));
|
||||
}
|
||||
}
|
||||
const SPAWN_PROVIDER = Object.freeze({
|
||||
name: "spawn",
|
||||
inheritsParentContext: false,
|
||||
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
|
||||
});
|
||||
const PERSONA_ORDER = 0;
|
||||
class SubagentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "subagents");
|
||||
}
|
||||
getProvider(name) {
|
||||
return name === "spawn" ? SPAWN_PROVIDER : void 0;
|
||||
}
|
||||
async startActivation(request) {
|
||||
const invocation = host2.toolInvocations.getStore();
|
||||
const owner = this.ctx[OWNER];
|
||||
const plain = (value, allowed, what) => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
|
||||
const result2 = /* @__PURE__ */ Object.create(null);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
|
||||
if (descriptor.value !== void 0) result2[key] = descriptor.value;
|
||||
}
|
||||
return result2;
|
||||
};
|
||||
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
|
||||
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
|
||||
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
|
||||
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
|
||||
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
|
||||
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
|
||||
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
|
||||
const childId = input.childId;
|
||||
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
|
||||
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
|
||||
const prompt = spawn.prompt.map((block) => {
|
||||
const item = plain(block, ["type", "text"], "prompt block");
|
||||
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
|
||||
return item.text;
|
||||
}).join("");
|
||||
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
|
||||
if (!invocation || !invocation.active || invocation.signal.aborted || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
|
||||
}
|
||||
if (invocation.owner !== owner || owner?.state !== "active" || owner.disposing) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
|
||||
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
|
||||
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
|
||||
signal.throwIfAborted();
|
||||
let persona;
|
||||
const denied2 = /* @__PURE__ */ new Set();
|
||||
let open2 = true;
|
||||
const sealed = () => {
|
||||
if (!open2) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
|
||||
};
|
||||
const child = Object.freeze({
|
||||
id: childId,
|
||||
ctx: Object.freeze({
|
||||
systemPrompt: Object.freeze({
|
||||
getSectionOrder(name) {
|
||||
sealed();
|
||||
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
|
||||
return PERSONA_ORDER;
|
||||
},
|
||||
section(section) {
|
||||
sealed();
|
||||
const value = plain(section, ["name", "order", "text"], "system prompt section");
|
||||
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
|
||||
}
|
||||
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
|
||||
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
|
||||
persona = value.text;
|
||||
return () => void 0;
|
||||
}
|
||||
}),
|
||||
tools: Object.freeze({
|
||||
restrict(filter) {
|
||||
sealed();
|
||||
const value = plain(filter, ["deny"], "tool filter");
|
||||
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
|
||||
for (const name of value.deny) denied2.add(name);
|
||||
return () => void 0;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
const listeners = (host2.root.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx?.[OWNER] === owner).map((hook) => hook.callback);
|
||||
try {
|
||||
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
|
||||
} finally {
|
||||
open2 = false;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const call = invocation;
|
||||
const rpc3 = (method, params, abort) => host2.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
|
||||
const started = await rpc3("subagent/start", {
|
||||
prompt,
|
||||
...options.model === void 0 ? {} : { model: options.model },
|
||||
...persona === void 0 ? {} : { persona },
|
||||
denied_tools: [...denied2],
|
||||
delivery
|
||||
}, signal);
|
||||
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
|
||||
}
|
||||
let result;
|
||||
let disposed;
|
||||
return Object.freeze({
|
||||
childId,
|
||||
messageId: started.message_id,
|
||||
get result() {
|
||||
result ??= rpc3("subagent/result", {}, call.signal).then((value) => {
|
||||
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
|
||||
}
|
||||
return Object.freeze({
|
||||
stopReason: value.stop_reason,
|
||||
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
|
||||
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
},
|
||||
dispose() {
|
||||
disposed ??= rpc3("subagent/dispose", {}, call.signal).then(() => void 0);
|
||||
return disposed;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
class AgentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "agents");
|
||||
}
|
||||
}
|
||||
class SystemPromptShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "systemPrompt");
|
||||
}
|
||||
section() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context");
|
||||
}
|
||||
getSectionOrder() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context");
|
||||
}
|
||||
}
|
||||
Object.freeze(SubagentsShim.prototype);
|
||||
Object.freeze(AgentsShim.prototype);
|
||||
Object.freeze(SystemPromptShim.prototype);
|
||||
Object.freeze(UserQuestionsShim.prototype);
|
||||
Object.freeze(SessionsShim.prototype);
|
||||
Object.freeze(StorageShim.prototype);
|
||||
shimClasses.set("tools", ToolsShim);
|
||||
shimClasses.set("commands", CommandsShim);
|
||||
shimClasses.set("prompt", PromptShim);
|
||||
shimClasses.set("storage", StorageShim);
|
||||
shimClasses.set("sessions", SessionsShim);
|
||||
shimClasses.set("userQuestions", UserQuestionsShim);
|
||||
shimClasses.set("subagents", SubagentsShim);
|
||||
shimClasses.set("agents", AgentsShim);
|
||||
shimClasses.set("systemPrompt", SystemPromptShim);
|
||||
shimClasses.set("skills", SkillsShim);
|
||||
shimClasses.set("avatars", AvatarsShim);
|
||||
shimClasses.set("mcp", McpShim);
|
||||
shimClasses.set("shellHooks", ShellHooksShim);
|
||||
root.plugin(ToolsShim);
|
||||
root.plugin(CommandsShim);
|
||||
root.plugin(PromptShim);
|
||||
root.plugin(StorageShim);
|
||||
root.plugin(SessionsShim);
|
||||
root.plugin(UserQuestionsShim);
|
||||
root.plugin(SubagentsShim);
|
||||
root.plugin(AgentsShim);
|
||||
root.plugin(SystemPromptShim);
|
||||
root.plugin(SkillsShim);
|
||||
root.plugin(AvatarsShim);
|
||||
root.plugin(McpShim);
|
||||
root.plugin(ShellHooksShim);
|
||||
const adapterHost = {
|
||||
tier,
|
||||
rpc: rpc2,
|
||||
ownerOf: (ctx) => ctx[OWNER],
|
||||
liveInvocation: (ctx) => {
|
||||
const invocation = host2.toolInvocations.getStore();
|
||||
const owner = ctx[OWNER];
|
||||
return invocation && invocation.active && !invocation.signal.aborted && owner !== void 0 && invocation.owner === owner && owner.state === "active" && !owner.disposing ? invocation : void 0;
|
||||
},
|
||||
log: (level, message, owner) => host2.log(level, message, owner),
|
||||
broker: async (ctx, service, method, input) => {
|
||||
const invocation = adapterHost.liveInvocation(ctx);
|
||||
const ticket = invocation?.brokerTickets.get(service);
|
||||
if (invocation === void 0 || ticket === void 0) throw new RpcError(ErrorCode.NotAvailable, `the ${String(service).slice(0, 64)} broker is only available to a live tool invocation the core gave a ticket for it`);
|
||||
return rpc2.request("dsh/broker", { owner: invocation.owner.ref, ticket, service, method, input }, invocation.signal);
|
||||
}
|
||||
};
|
||||
const shims = /* @__PURE__ */ new Map([
|
||||
["tools", ToolsShim],
|
||||
["commands", CommandsShim],
|
||||
["prompt", PromptShim],
|
||||
["storage", StorageShim],
|
||||
["userQuestions", UserQuestionsShim],
|
||||
["skills", SkillsShim],
|
||||
["avatars", AvatarsShim],
|
||||
["mcp", McpShim],
|
||||
["shellHooks", ShellHooksShim]
|
||||
]);
|
||||
for (const adapter of adapters) {
|
||||
if (shims.has(adapter.service)) throw new Error(`DSH adapter for \`${adapter.service}\` duplicates a service the host root already provides`);
|
||||
shims.set(adapter.service, adapter.define(adapterHost));
|
||||
}
|
||||
const mismatched = [
|
||||
...[...shims.keys()].filter((name) => !PROVIDED_SERVICES.has(name)).map((name) => `${name} is installed but not declared in compatibility.json`),
|
||||
...[...PROVIDED_SERVICES].filter((name) => !CORDIS_SERVICES.has(name) && !shims.has(name)).map((name) => `${name} is declared in compatibility.json but has no installed service`)
|
||||
];
|
||||
if (mismatched.length > 0) throw new Error(`DSH provided services disagree with the installed services: ${mismatched.join("; ")}`);
|
||||
for (const [name, shim] of shims) {
|
||||
shimClasses.set(name, shim);
|
||||
root.plugin(shim);
|
||||
}
|
||||
shimClasses.set("loader", ReviewedLoader);
|
||||
installCompositionLoader(root);
|
||||
}
|
||||
@@ -17910,7 +18003,8 @@ var init_root = __esm({
|
||||
const plugin = pickPlugin(module);
|
||||
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => !PROVIDED_SERVICES.has(name));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(", ")}, not provided by the Codewhale extension host in this phase`);
|
||||
const owned = missing.filter((name) => REFUSED_SERVICES.has(name));
|
||||
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(", ")}, not provided by the Codewhale extension host in this phase` + (owned.length > 0 ? `; ${owned.map((name) => `\`${name}\``).join(", ")} belong${owned.length === 1 ? "s" : ""} to the Codewhale core and no reviewed bridge exposes ${owned.length === 1 ? "it" : "them"} yet` : ""));
|
||||
}
|
||||
const ownerCtx = this.root.extend({ [OWNER]: owner });
|
||||
const fiber = ownerStorage.run(owner, () => ownerCtx.plugin(plugin, params.config ?? {}));
|
||||
@@ -18059,6 +18153,7 @@ var init_root = __esm({
|
||||
agent,
|
||||
questionTicket: identity?.question_ticket,
|
||||
subagentTicket: identity?.subagent_ticket,
|
||||
brokerTickets: new Map((identity?.broker_tickets ?? []).map(({ service, ticket: ticket2 }) => [service, ticket2])),
|
||||
callerTools: Object.freeze([...identity?.caller_tools ?? []]),
|
||||
delegated: identity?.agent_id !== void 0,
|
||||
ticket: identity?.checkpoint_ticket,
|
||||
|
||||
+278
-14
@@ -5423,14 +5423,14 @@ function requireDumper() {
|
||||
let hasFoldableLine = false;
|
||||
const shouldTrackWidth = lineWidth !== -1;
|
||||
let previousLineBreak = -1;
|
||||
let plain2 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
|
||||
let plain3 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
|
||||
if (singleLineOnly || forceQuotes) {
|
||||
for (i2 = 0; i2 < string.length; char >= 65536 ? i2 += 2 : i2++) {
|
||||
char = codePointAt(string, i2);
|
||||
if (!isPrintable(char)) {
|
||||
return STYLE_DOUBLE;
|
||||
}
|
||||
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
|
||||
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
|
||||
prevChar = char;
|
||||
}
|
||||
} else {
|
||||
@@ -5446,13 +5446,13 @@ function requireDumper() {
|
||||
} else if (!isPrintable(char)) {
|
||||
return STYLE_DOUBLE;
|
||||
}
|
||||
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
|
||||
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
|
||||
prevChar = char;
|
||||
}
|
||||
hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i2 - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " ");
|
||||
}
|
||||
if (!hasLineBreak && !hasFoldableLine) {
|
||||
if (plain2 && !forceQuotes && !testAmbiguousType(string)) {
|
||||
if (plain3 && !forceQuotes && !testAmbiguousType(string)) {
|
||||
return STYLE_PLAIN;
|
||||
}
|
||||
return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE;
|
||||
@@ -13229,11 +13229,23 @@ var init_prompt = __esm({
|
||||
});
|
||||
|
||||
// src/protocol.generated.ts
|
||||
var MAGIC_ASCII;
|
||||
var MAGIC_ASCII, ErrorCode;
|
||||
var init_protocol_generated = __esm({
|
||||
"src/protocol.generated.ts"() {
|
||||
"use strict";
|
||||
MAGIC_ASCII = "CWX1";
|
||||
ErrorCode = {
|
||||
ParseError: -32700,
|
||||
InvalidRequest: -32600,
|
||||
MethodNotFound: -32601,
|
||||
InvalidParams: -32602,
|
||||
Internal: -32603,
|
||||
ExecutionFailed: -32e3,
|
||||
NotAvailable: -32001,
|
||||
Refused: -32002,
|
||||
Denied: -32003,
|
||||
Cancelled: -32800
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13249,10 +13261,88 @@ var init_protocol = __esm({
|
||||
});
|
||||
|
||||
// src/rpc.ts
|
||||
var RpcError;
|
||||
var init_rpc = __esm({
|
||||
"src/rpc.ts"() {
|
||||
"use strict";
|
||||
init_protocol();
|
||||
RpcError = class extends Error {
|
||||
constructor(code, message, data2) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.data = data2;
|
||||
this.name = "RpcError";
|
||||
}
|
||||
code;
|
||||
data;
|
||||
toWire() {
|
||||
return this.data === void 0 ? { code: this.code, message: this.message } : { code: this.code, message: this.message, data: this.data };
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/session-prompt.ts
|
||||
function checkpointCall(host, ctx, session) {
|
||||
const invocation = host.liveInvocation(ctx);
|
||||
const ticket = invocation?.ticket;
|
||||
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
|
||||
}
|
||||
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
|
||||
var init_session_prompt = __esm({
|
||||
"src/dsh/session-prompt.ts"() {
|
||||
"use strict";
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
|
||||
sessionsAdapter = {
|
||||
service: "sessions",
|
||||
define(host) {
|
||||
class SessionsService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "sessions");
|
||||
}
|
||||
async flush(session) {
|
||||
const ctx = this.ctx;
|
||||
const call = checkpointCall(host, ctx, session);
|
||||
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
|
||||
const owner = call.owner;
|
||||
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted;
|
||||
const run = async () => {
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
const result2 = await host.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
|
||||
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
|
||||
return result2;
|
||||
};
|
||||
const result = call.pending.then(run);
|
||||
call.pending = result.then(() => void 0, () => void 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Object.freeze(SessionsService.prototype);
|
||||
return SessionsService;
|
||||
}
|
||||
};
|
||||
systemPromptAdapter = {
|
||||
service: "systemPrompt",
|
||||
define() {
|
||||
class SystemPromptService extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "systemPrompt");
|
||||
}
|
||||
section() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
|
||||
}
|
||||
getSectionOrder() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
|
||||
}
|
||||
}
|
||||
Object.freeze(SystemPromptService.prototype);
|
||||
return SystemPromptService;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13884,11 +13974,12 @@ var init_lib3 = __esm({
|
||||
});
|
||||
|
||||
// src/dsh/dsh-message-compat.js
|
||||
var HarnessError, UserQuestionError;
|
||||
var PERSONA_PREFIX_SECTION, HarnessError, UserQuestionError;
|
||||
var init_dsh_message_compat = __esm({
|
||||
"src/dsh/dsh-message-compat.js"() {
|
||||
"use strict";
|
||||
init_lib3();
|
||||
PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
|
||||
HarnessError = class extends Error {
|
||||
code;
|
||||
constructor(message, code, options) {
|
||||
@@ -13908,6 +13999,177 @@ var init_dsh_message_compat = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/agents.ts
|
||||
function plain(value, allowed, what) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
|
||||
const result = /* @__PURE__ */ Object.create(null);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
|
||||
if (descriptor.value !== void 0) result[key] = descriptor.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function defineSubagents(host) {
|
||||
class SubagentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "subagents");
|
||||
}
|
||||
getProvider(name) {
|
||||
return name === "spawn" ? SPAWN_PROVIDER : void 0;
|
||||
}
|
||||
async startActivation(request) {
|
||||
const owner = host.ownerOf(this.ctx);
|
||||
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
|
||||
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
|
||||
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
|
||||
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
|
||||
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
|
||||
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
|
||||
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
|
||||
const childId = input.childId;
|
||||
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
|
||||
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
|
||||
const prompt = spawn.prompt.map((block) => {
|
||||
const item = plain(block, ["type", "text"], "prompt block");
|
||||
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
|
||||
return item.text;
|
||||
}).join("");
|
||||
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
|
||||
const invocation = host.liveInvocation(this.ctx);
|
||||
if (!invocation || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
|
||||
}
|
||||
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
|
||||
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
|
||||
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
|
||||
signal.throwIfAborted();
|
||||
let persona;
|
||||
const denied = /* @__PURE__ */ new Set();
|
||||
let open = true;
|
||||
const sealed = () => {
|
||||
if (!open) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
|
||||
};
|
||||
const child = Object.freeze({
|
||||
id: childId,
|
||||
ctx: Object.freeze({
|
||||
systemPrompt: Object.freeze({
|
||||
getSectionOrder(name) {
|
||||
sealed();
|
||||
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
|
||||
return PERSONA_ORDER;
|
||||
},
|
||||
section(section) {
|
||||
sealed();
|
||||
const value = plain(section, ["name", "order", "text"], "system prompt section");
|
||||
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
|
||||
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
|
||||
}
|
||||
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
|
||||
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
|
||||
persona = value.text;
|
||||
return () => void 0;
|
||||
}
|
||||
}),
|
||||
tools: Object.freeze({
|
||||
restrict(filter) {
|
||||
sealed();
|
||||
const value = plain(filter, ["deny"], "tool filter");
|
||||
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
|
||||
for (const name of value.deny) denied.add(name);
|
||||
return () => void 0;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
const listeners = (this.ctx.root?.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx !== void 0 && host.ownerOf(hook.ctx) === owner).map((hook) => hook.callback);
|
||||
try {
|
||||
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
|
||||
} finally {
|
||||
open = false;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const call = invocation;
|
||||
const rpc = (method, params, abort) => host.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
|
||||
const started = await rpc("subagent/start", {
|
||||
prompt,
|
||||
...options.model === void 0 ? {} : { model: options.model },
|
||||
...persona === void 0 ? {} : { persona },
|
||||
denied_tools: [...denied],
|
||||
delivery
|
||||
}, signal);
|
||||
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
|
||||
}
|
||||
let result;
|
||||
let disposed;
|
||||
return Object.freeze({
|
||||
childId,
|
||||
messageId: started.message_id,
|
||||
get result() {
|
||||
result ??= rpc("subagent/result", {}, call.signal).then((value) => {
|
||||
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
|
||||
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
|
||||
}
|
||||
return Object.freeze({
|
||||
stopReason: value.stop_reason,
|
||||
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
|
||||
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
},
|
||||
dispose() {
|
||||
disposed ??= rpc("subagent/dispose", {}, call.signal).then(() => void 0);
|
||||
return disposed;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.freeze(SubagentsShim.prototype);
|
||||
return SubagentsShim;
|
||||
}
|
||||
function defineAgents() {
|
||||
class AgentsShim extends Service {
|
||||
constructor(ctx) {
|
||||
super(ctx, "agents");
|
||||
}
|
||||
}
|
||||
Object.freeze(AgentsShim.prototype);
|
||||
return AgentsShim;
|
||||
}
|
||||
var SPAWN_PROVIDER, PERSONA_ORDER, DSH_AGENT_ADAPTERS;
|
||||
var init_agents = __esm({
|
||||
"src/dsh/agents.ts"() {
|
||||
"use strict";
|
||||
init_lib2();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
init_dsh_message_compat();
|
||||
SPAWN_PROVIDER = Object.freeze({
|
||||
name: "spawn",
|
||||
inheritsParentContext: false,
|
||||
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
|
||||
});
|
||||
PERSONA_ORDER = 0;
|
||||
DSH_AGENT_ADAPTERS = Object.freeze([
|
||||
{ service: "subagents", define: defineSubagents },
|
||||
{ service: "agents", define: defineAgents }
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/adapters.ts
|
||||
var DSH_ADAPTERS;
|
||||
var init_adapters = __esm({
|
||||
"src/dsh/adapters.ts"() {
|
||||
"use strict";
|
||||
init_session_prompt();
|
||||
init_agents();
|
||||
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/dsh-tools-compat.js
|
||||
var init_dsh_tools_compat = __esm({
|
||||
"src/dsh/dsh-tools-compat.js"() {
|
||||
@@ -13989,11 +14251,12 @@ var init_commands = __esm({
|
||||
|
||||
// src/root.ts
|
||||
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
|
||||
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
|
||||
var REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ownerStorage;
|
||||
var init_root = __esm({
|
||||
"src/root.ts"() {
|
||||
"use strict";
|
||||
init_composition();
|
||||
init_adapters();
|
||||
init_protocol();
|
||||
init_rpc();
|
||||
init_dsh_message_compat();
|
||||
@@ -14015,6 +14278,7 @@ var init_root = __esm({
|
||||
init_commands();
|
||||
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
|
||||
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
|
||||
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
|
||||
ownerStorage = new AsyncLocalStorage2();
|
||||
}
|
||||
});
|
||||
@@ -14335,7 +14599,7 @@ var init_composition_inventory = __esm({
|
||||
import { createHash as createHash2 } from "node:crypto";
|
||||
import { resolve as resolve4, relative as relative2, dirname as dirname4, sep as sep2 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath4, pathToFileURL as pathToFileURL5 } from "node:url";
|
||||
function plain(path) {
|
||||
function plain2(path) {
|
||||
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !/[\\:\u0000-\u001f\u007f]/u.test(path) && !path.startsWith("/") && path.split("/").every((p) => p !== "" && p !== "." && p !== "..");
|
||||
}
|
||||
function fail(message) {
|
||||
@@ -14375,7 +14639,7 @@ function containedPackageModule(name, documents, files) {
|
||||
entry = value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).some((k) => k.startsWith(".")) ? target(value[subpath]) : subpath === "." ? target(value) : void 0;
|
||||
} else if (subpath === ".") entry = typeof data2.main === "string" ? data2.main : "./index.js";
|
||||
else entry = subpath;
|
||||
if (typeof entry !== "string" || !entry.startsWith("./") || !plain(entry.slice(2))) return;
|
||||
if (typeof entry !== "string" || !entry.startsWith("./") || !plain2(entry.slice(2))) return;
|
||||
const path = `${packageRoot}/${entry.slice(2)}`;
|
||||
if (!/\.(mjs|js|mts)$/.test(path) || !SHA.test(files[path] ?? "")) return;
|
||||
return { name, path, sha256: files[path] };
|
||||
@@ -14388,20 +14652,20 @@ async function reviewAgentPresets(input) {
|
||||
const baseUrl = pathToFileURL5(`${base}${sep2}`).href;
|
||||
function key(path) {
|
||||
const inside = relative2(base, path).split(sep2).join("/");
|
||||
if (!plain(inside)) fail("path escapes the admitted source root");
|
||||
if (!plain2(inside)) fail("path escapes the admitted source root");
|
||||
return inside;
|
||||
}
|
||||
const documents = /* @__PURE__ */ new Map();
|
||||
let bytes = 0;
|
||||
for (const doc of input.documents) {
|
||||
if (!plain(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
|
||||
if (!plain2(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
|
||||
bytes += Buffer.byteLength(doc.source);
|
||||
if (Buffer.byteLength(doc.source) > 1024 * 1024 || bytes > 4 * 1024 * 1024) fail("documents exceed their bound");
|
||||
documents.set(doc.path, doc.source);
|
||||
}
|
||||
const directories = new Set(input.directories);
|
||||
for (const path of directories) if (!plain(path)) fail("invalid source directory");
|
||||
for (const path of Object.keys(files)) if (!plain(path) || !SHA.test(files[path])) fail("invalid source file receipt");
|
||||
for (const path of directories) if (!plain2(path)) fail("invalid source directory");
|
||||
for (const path of Object.keys(files)) if (!plain2(path) || !SHA.test(files[path])) fail("invalid source file receipt");
|
||||
const invalid = /* @__PURE__ */ new Set();
|
||||
const parsed = /* @__PURE__ */ new Map();
|
||||
const io = {
|
||||
@@ -14466,7 +14730,7 @@ async function reviewAgentPresets(input) {
|
||||
}
|
||||
if (config.roots !== void 0 && !Array.isArray(config.roots)) fail("roots must be a literal list");
|
||||
for (const root of config.roots ?? []) {
|
||||
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
|
||||
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain2(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
|
||||
if (!directories.has(root.path)) fail("configured preset root was not admitted");
|
||||
roots.push({ path: resolve4(base, root.path), trust: root.trust });
|
||||
}
|
||||
|
||||
+14
-1
@@ -18,6 +18,7 @@ var ErrorCode = {
|
||||
Denied: -32003,
|
||||
Cancelled: -32800
|
||||
};
|
||||
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote"];
|
||||
var METHODS = [
|
||||
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams", tiers: ["plugin", "builtin"] },
|
||||
@@ -42,6 +43,7 @@ var METHODS = [
|
||||
{ name: "subagent/start", direction: "host_to_core", request: true, params: "SubagentStartParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "subagent/result", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "subagent/dispose", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "dsh/broker", direction: "host_to_core", request: true, params: "DshBrokerParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
|
||||
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
|
||||
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
|
||||
@@ -82,6 +84,16 @@ var SHAPES = {
|
||||
required: { owner: { ref: "OwnerRef" } },
|
||||
optional: { entry: { ref: "EntryRef" } }
|
||||
},
|
||||
DshBrokerParams: {
|
||||
strict: true,
|
||||
required: { owner: { ref: "OwnerRef" }, ticket: "string", service: "string", method: "string", input: "json" },
|
||||
optional: {}
|
||||
},
|
||||
DshBrokerTicketWire: {
|
||||
strict: true,
|
||||
required: { service: "string", ticket: "string" },
|
||||
optional: {}
|
||||
},
|
||||
EmptyParams: {
|
||||
strict: true,
|
||||
required: {},
|
||||
@@ -245,7 +257,7 @@ var SHAPES = {
|
||||
ToolCallParams: {
|
||||
strict: false,
|
||||
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
|
||||
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
|
||||
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", broker_tickets: { items: { ref: "DshBrokerTicketWire" } }, caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
|
||||
},
|
||||
UnregisterParams: {
|
||||
strict: true,
|
||||
@@ -415,6 +427,7 @@ function validateMessage(value, direction, tier, methods = METHODS) {
|
||||
return value;
|
||||
}
|
||||
export {
|
||||
DSH_SERVICES,
|
||||
ErrorCode,
|
||||
FrameDecoder,
|
||||
FrameError,
|
||||
|
||||
+12
-1
@@ -39,6 +39,7 @@ var METHODS = [
|
||||
{ name: "subagent/start", direction: "host_to_core", request: true, params: "SubagentStartParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "subagent/result", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "subagent/dispose", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "dsh/broker", direction: "host_to_core", request: true, params: "DshBrokerParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
|
||||
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
|
||||
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
|
||||
@@ -79,6 +80,16 @@ var SHAPES = {
|
||||
required: { owner: { ref: "OwnerRef" } },
|
||||
optional: { entry: { ref: "EntryRef" } }
|
||||
},
|
||||
DshBrokerParams: {
|
||||
strict: true,
|
||||
required: { owner: { ref: "OwnerRef" }, ticket: "string", service: "string", method: "string", input: "json" },
|
||||
optional: {}
|
||||
},
|
||||
DshBrokerTicketWire: {
|
||||
strict: true,
|
||||
required: { service: "string", ticket: "string" },
|
||||
optional: {}
|
||||
},
|
||||
EmptyParams: {
|
||||
strict: true,
|
||||
required: {},
|
||||
@@ -242,7 +253,7 @@ var SHAPES = {
|
||||
ToolCallParams: {
|
||||
strict: false,
|
||||
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
|
||||
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
|
||||
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", broker_tickets: { items: { ref: "DshBrokerTicketWire" } }, caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
|
||||
},
|
||||
UnregisterParams: {
|
||||
strict: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Service } from '@deepseek-ai/cordis'
|
||||
import type { DshService } from '../protocol.generated.ts'
|
||||
import type { OwnerRecord, ToolInvocation } from '../root.ts'
|
||||
import type { RpcPeer } from '../rpc.ts'
|
||||
import type { HostTier } from '../tier.ts'
|
||||
import { sessionsAdapter, systemPromptAdapter } from './session-prompt.ts'
|
||||
import { DSH_AGENT_ADAPTERS } from './agents.ts'
|
||||
|
||||
export interface AdapterHost {
|
||||
readonly tier: HostTier
|
||||
readonly rpc: RpcPeer
|
||||
ownerOf(ctx: unknown): OwnerRecord | undefined
|
||||
liveInvocation(ctx: unknown): ToolInvocation | undefined
|
||||
broker(ctx: unknown, service: DshService, method: string, input: unknown): Promise<unknown>
|
||||
log(level: 'info' | 'warn' | 'error', message: string, owner?: OwnerRecord): void
|
||||
}
|
||||
|
||||
export interface DshAdapter {
|
||||
readonly service: string
|
||||
define(host: AdapterHost): new (ctx: any) => Service
|
||||
}
|
||||
|
||||
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS]
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import { PERSONA_PREFIX_SECTION } from './dsh-message-compat.js'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
const SPAWN_PROVIDER = Object.freeze({
|
||||
name: 'spawn',
|
||||
inheritsParentContext: false,
|
||||
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true }),
|
||||
})
|
||||
const PERSONA_ORDER = 0
|
||||
|
||||
function plain(value: unknown, allowed: string[], what: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
||||
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`)
|
||||
const result: Record<string, unknown> = Object.create(null)
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
|
||||
if (typeof key !== 'string' || !allowed.includes(key) || !('value' in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`)
|
||||
if (descriptor.value !== undefined) result[key] = descriptor.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function defineSubagents(host: AdapterHost): new (ctx: any) => Service {
|
||||
class SubagentsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'subagents') }
|
||||
getProvider(name: unknown) { return name === 'spawn' ? SPAWN_PROVIDER : undefined }
|
||||
async startActivation(request: any) {
|
||||
const owner = host.ownerOf(this.ctx)
|
||||
const input = plain(request, ['provider', 'label', 'childId', 'request', 'signal', 'delivery'], 'activation')
|
||||
if (input.provider !== 'spawn') throw new RpcError(ErrorCode.NotAvailable, 'only the spawn provider is available')
|
||||
const spawn = plain(input.request, ['parent', 'prompt', 'agentOptions'], 'spawn request')
|
||||
const options = spawn.agentOptions === undefined ? {} : plain(spawn.agentOptions, ['model'], 'agentOptions')
|
||||
if (options.model !== undefined && typeof options.model !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'agentOptions.model must be a string')
|
||||
const delivery = input.delivery === undefined ? 'parent' : input.delivery
|
||||
if (delivery !== 'caller' && delivery !== 'parent') throw new RpcError(ErrorCode.InvalidParams, 'delivery must be caller or parent')
|
||||
const childId = input.childId
|
||||
if (typeof childId !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, 'childId must be 1-128 letters, digits, or _.:-')
|
||||
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'prompt must be a non-empty content array')
|
||||
const prompt = spawn.prompt.map((block: unknown) => {
|
||||
const item = plain(block, ['type', 'text'], 'prompt block')
|
||||
if (item.type !== 'text' || typeof item.text !== 'string') throw new RpcError(ErrorCode.NotAvailable, 'only text prompt blocks are supported')
|
||||
return item.text
|
||||
}).join('')
|
||||
if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, 'signal must be an AbortSignal')
|
||||
const invocation = host.liveInvocation(this.ctx)
|
||||
if (!invocation || invocation.agent === undefined || spawn.parent !== invocation.agent) {
|
||||
throw new RpcError(ErrorCode.NotAvailable, 'startActivation requires the exact live calling agent as parent')
|
||||
}
|
||||
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, 'subagents service owner is no longer current')
|
||||
if (invocation.delegated || invocation.subagentTicket === undefined) throw new RpcError(ErrorCode.NotAvailable, 'child agents are only available to a root tool invocation')
|
||||
const signal = input.signal === undefined ? invocation.signal : AbortSignal.any([invocation.signal, input.signal as AbortSignal])
|
||||
signal.throwIfAborted()
|
||||
let persona: string | undefined
|
||||
const denied = new Set<string>()
|
||||
let open = true
|
||||
const sealed = () => { if (!open) throw new RpcError(ErrorCode.NotAvailable, 'the child creation window has closed') }
|
||||
const child = Object.freeze({
|
||||
id: childId,
|
||||
ctx: Object.freeze({
|
||||
systemPrompt: Object.freeze({
|
||||
getSectionOrder(name: unknown) {
|
||||
sealed()
|
||||
if (name !== 'DEPLOYMENT_PERSONA_PREFIX') throw new RpcError(ErrorCode.NotAvailable, 'only the deployment persona slot is available')
|
||||
return PERSONA_ORDER
|
||||
},
|
||||
section(section: unknown) {
|
||||
sealed()
|
||||
const value = plain(section, ['name', 'order', 'text'], 'system prompt section')
|
||||
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== 'string') {
|
||||
throw new RpcError(ErrorCode.NotAvailable, 'only the child deployment persona section is supported')
|
||||
}
|
||||
if (persona !== undefined) throw new RpcError(ErrorCode.InvalidParams, 'the child persona is already set')
|
||||
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, 'child persona exceeds 32768 bytes')
|
||||
persona = value.text
|
||||
return () => undefined
|
||||
},
|
||||
}),
|
||||
tools: Object.freeze({
|
||||
restrict(filter: unknown) {
|
||||
sealed()
|
||||
const value = plain(filter, ['deny'], 'tool filter')
|
||||
if (!Array.isArray(value.deny) || value.deny.some(name => typeof name !== 'string' || name === '')) throw new RpcError(ErrorCode.InvalidParams, 'deny must be a list of tool names')
|
||||
for (const name of value.deny as string[]) denied.add(name)
|
||||
return () => undefined
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const listeners = (((this.ctx as any).root?.events?._hooks?.['agent/created']) ?? [])
|
||||
.filter((hook: any) => !hook.global && hook.ctx !== undefined && host.ownerOf(hook.ctx) === owner)
|
||||
.map((hook: any) => hook.callback)
|
||||
try {
|
||||
await Promise.all(listeners.map(async (callback: Function) => callback({ agent: child })))
|
||||
} finally {
|
||||
open = false
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const call = invocation
|
||||
const rpc = (method: string, params: Record<string, unknown>, abort: AbortSignal) =>
|
||||
host.rpc.request<any>(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort)
|
||||
const started = await rpc('subagent/start', {
|
||||
prompt,
|
||||
...(options.model === undefined ? {} : { model: options.model }),
|
||||
...(persona === undefined ? {} : { persona }),
|
||||
denied_tools: [...denied],
|
||||
delivery,
|
||||
}, signal)
|
||||
if (started === null || typeof started !== 'object' || started.child_id !== childId || typeof started.message_id !== 'string') {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid child activation receipt')
|
||||
}
|
||||
let result: Promise<unknown> | undefined
|
||||
let disposed: Promise<void> | undefined
|
||||
return Object.freeze({
|
||||
childId,
|
||||
messageId: started.message_id,
|
||||
get result() {
|
||||
result ??= rpc('subagent/result', {}, call.signal).then((value: any) => {
|
||||
if (value === null || typeof value !== 'object' || typeof value.stop_reason !== 'string' || !Array.isArray(value.output)) {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid child result receipt')
|
||||
}
|
||||
return Object.freeze({
|
||||
stopReason: value.stop_reason,
|
||||
output: Object.freeze(value.output.map((block: any) => Object.freeze({ type: 'text', text: String(block.text) }))),
|
||||
...(value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}),
|
||||
})
|
||||
})
|
||||
return result
|
||||
},
|
||||
dispose() {
|
||||
disposed ??= rpc('subagent/dispose', {}, call.signal).then(() => undefined)
|
||||
return disposed
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Object.freeze(SubagentsShim.prototype)
|
||||
return SubagentsShim
|
||||
}
|
||||
|
||||
function defineAgents(): new (ctx: any) => Service {
|
||||
class AgentsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'agents') }
|
||||
}
|
||||
Object.freeze(AgentsShim.prototype)
|
||||
return AgentsShim
|
||||
}
|
||||
|
||||
export const DSH_AGENT_ADAPTERS: readonly DshAdapter[] = Object.freeze([
|
||||
{ service: 'subagents', define: defineSubagents },
|
||||
{ service: 'agents', define: defineAgents },
|
||||
])
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
export const CREDENTIAL_HANDLE_PREFIX = 'codewhale-credential:'
|
||||
|
||||
interface BrokeredResponse {
|
||||
status: number
|
||||
url: string
|
||||
redirected: boolean
|
||||
headers: [string, string][]
|
||||
body_base64: string
|
||||
}
|
||||
|
||||
function credentialRef(ref: unknown): string {
|
||||
if (typeof ref !== 'string' || ref.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential ref is a non-empty string')
|
||||
return ref
|
||||
}
|
||||
|
||||
function brokered(value: unknown): BrokeredResponse {
|
||||
const response = value as Partial<BrokeredResponse> | null
|
||||
if (response === null || typeof response !== 'object' || typeof response.status !== 'number' || typeof response.url !== 'string'
|
||||
|| typeof response.body_base64 !== 'string' || !Array.isArray(response.headers)) {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid network broker response')
|
||||
}
|
||||
return response as BrokeredResponse
|
||||
}
|
||||
|
||||
async function requestBody(body: unknown): Promise<string | undefined> {
|
||||
if (body === undefined || body === null) return undefined
|
||||
if (typeof body === 'string') return Buffer.from(body, 'utf8').toString('base64')
|
||||
if (body instanceof ArrayBuffer) return Buffer.from(body).toString('base64')
|
||||
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('base64')
|
||||
if (body instanceof URLSearchParams) return Buffer.from(body.toString(), 'utf8').toString('base64')
|
||||
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer()).toString('base64')
|
||||
throw new RpcError(ErrorCode.InvalidParams, 'the network broker sends string, binary, URLSearchParams or Blob bodies')
|
||||
}
|
||||
|
||||
export async function brokeredFetch(host: AdapterHost, ctx: unknown, input: string | URL | Request, init: RequestInit = {}): Promise<Response> {
|
||||
const request = input instanceof Request ? input : undefined
|
||||
const url = request?.url ?? String(input)
|
||||
const headers = new Headers(request?.headers)
|
||||
new Headers(init.headers).forEach((value, name) => headers.set(name, value))
|
||||
const body = init.body !== undefined ? init.body : request !== undefined && request.body !== null ? await request.arrayBuffer() : undefined
|
||||
const result = brokered(await host.broker(ctx, 'network', 'fetch', {
|
||||
url,
|
||||
method: init.method ?? request?.method ?? 'GET',
|
||||
headers: [...headers.entries()],
|
||||
body_base64: await requestBody(body),
|
||||
}))
|
||||
const bytes = Buffer.from(result.body_base64, 'base64')
|
||||
const nullBody = [101, 204, 205, 304].includes(result.status)
|
||||
const response = new Response(nullBody ? null : bytes, { status: result.status, headers: result.headers })
|
||||
Object.defineProperty(response, 'url', { value: result.url })
|
||||
Object.defineProperty(response, 'redirected', { value: result.redirected === true })
|
||||
return response
|
||||
}
|
||||
|
||||
export const credentialsAdapter: DshAdapter = {
|
||||
service: 'credentials',
|
||||
define(host) {
|
||||
class CredentialsService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'credentials') }
|
||||
|
||||
async resolve(ref: unknown): Promise<{ value: string } | undefined> {
|
||||
const result = await host.broker(this.ctx, 'network', 'credentials.resolve', { ref: credentialRef(ref) }) as { value?: unknown } | null
|
||||
const value = result?.value
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value !== 'string' || !value.startsWith(CREDENTIAL_HANDLE_PREFIX)) throw new RpcError(ErrorCode.Internal, 'invalid credential handle')
|
||||
return { value }
|
||||
}
|
||||
|
||||
async describe(ref: unknown): Promise<{ ref: string, set: boolean }> {
|
||||
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown } | null
|
||||
if (result === null || typeof result !== 'object' || typeof result.set !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid credential description')
|
||||
return { ref: credentialRef(ref), set: result.set }
|
||||
}
|
||||
|
||||
async set(ref: unknown, value: unknown): Promise<void> {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential value is a non-empty string')
|
||||
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value })
|
||||
}
|
||||
|
||||
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
return brokeredFetch(host, this.ctx, input, init)
|
||||
}
|
||||
}
|
||||
Object.freeze(CredentialsService.prototype)
|
||||
return CredentialsService
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { OwnerRecord, ToolInvocation } from '../root.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
const CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context"
|
||||
|
||||
function checkpointCall(host: AdapterHost, ctx: unknown, session: unknown): ToolInvocation | undefined {
|
||||
const invocation = host.liveInvocation(ctx)
|
||||
const ticket = invocation?.ticket
|
||||
return invocation !== undefined && typeof ticket === 'string' && ticket.length > 0
|
||||
&& session !== undefined && session === invocation.session
|
||||
? invocation
|
||||
: undefined
|
||||
}
|
||||
|
||||
export const sessionsAdapter: DshAdapter = {
|
||||
service: 'sessions',
|
||||
define(host) {
|
||||
class SessionsService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'sessions') }
|
||||
|
||||
async flush(session: unknown): Promise<boolean> {
|
||||
const ctx = this.ctx
|
||||
const call = checkpointCall(host, ctx, session)
|
||||
if (call === undefined) throw new RpcError(ErrorCode.NotAvailable, 'sessions.flush requires this live tool invocation’s session')
|
||||
const owner: OwnerRecord = call.owner
|
||||
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted
|
||||
const run = async () => {
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
|
||||
const result = await host.rpc.request<unknown>('session/checkpoint', { owner: owner.ref, ticket: call.ticket }, call.signal)
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
|
||||
if (typeof result !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid session checkpoint receipt')
|
||||
return result
|
||||
}
|
||||
const result = call.pending.then(run)
|
||||
call.pending = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
}
|
||||
Object.freeze(SessionsService.prototype)
|
||||
return SessionsService
|
||||
},
|
||||
}
|
||||
|
||||
export const systemPromptAdapter: DshAdapter = {
|
||||
service: 'systemPrompt',
|
||||
define() {
|
||||
class SystemPromptService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'systemPrompt') }
|
||||
section() { throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY) }
|
||||
getSectionOrder() { throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY) }
|
||||
}
|
||||
Object.freeze(SystemPromptService.prototype)
|
||||
return SystemPromptService
|
||||
},
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export const ErrorCode = {
|
||||
|
||||
export type Direction = 'core_to_host' | 'host_to_core'
|
||||
|
||||
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote'] as const
|
||||
export type DshService = typeof DSH_SERVICES[number]
|
||||
|
||||
/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */
|
||||
export const METHODS = [
|
||||
{ name: 'host/initialize', direction: 'core_to_host', request: true, params: 'InitializeParams', tiers: ['plugin', 'builtin'] },
|
||||
@@ -50,6 +53,7 @@ export const METHODS = [
|
||||
{ name: 'subagent/start', direction: 'host_to_core', request: true, params: 'SubagentStartParams', tiers: ['plugin', 'builtin'] },
|
||||
{ name: 'subagent/result', direction: 'host_to_core', request: true, params: 'SubagentHandleParams', tiers: ['plugin', 'builtin'] },
|
||||
{ name: 'subagent/dispose', direction: 'host_to_core', request: true, params: 'SubagentHandleParams', tiers: ['plugin', 'builtin'] },
|
||||
{ name: 'dsh/broker', direction: 'host_to_core', request: true, params: 'DshBrokerParams', tiers: ['plugin', 'builtin'] },
|
||||
{ name: 'proc/launch', direction: 'host_to_core', request: true, params: 'ProcLaunchParams', tiers: ['builtin'] },
|
||||
{ name: 'proc/read', direction: 'host_to_core', request: true, params: 'ProcSessionParams', tiers: ['builtin'] },
|
||||
{ name: 'proc/write', direction: 'host_to_core', request: true, params: 'ProcWriteParams', tiers: ['builtin'] },
|
||||
@@ -111,6 +115,16 @@ export const SHAPES: { readonly [name: string]: Shape } = {
|
||||
required: { owner: { ref: 'OwnerRef' } },
|
||||
optional: { entry: { ref: 'EntryRef' } },
|
||||
},
|
||||
DshBrokerParams: {
|
||||
strict: true,
|
||||
required: { owner: { ref: 'OwnerRef' }, ticket: 'string', service: 'string', method: 'string', input: 'json' },
|
||||
optional: {},
|
||||
},
|
||||
DshBrokerTicketWire: {
|
||||
strict: true,
|
||||
required: { service: 'string', ticket: 'string' },
|
||||
optional: {},
|
||||
},
|
||||
EmptyParams: {
|
||||
strict: true,
|
||||
required: {},
|
||||
@@ -274,7 +288,7 @@ export const SHAPES: { readonly [name: string]: Shape } = {
|
||||
ToolCallParams: {
|
||||
strict: false,
|
||||
required: { handle: 'uint', call_id: 'string', input: 'json', deadline_ms: 'uint' },
|
||||
optional: { workspace: 'string', ticket: 'string', checkpoint_ticket: 'string', question_ticket: 'string', subagent_ticket: 'string', caller_tools: { items: 'string' }, session_id: 'string', agent_id: 'string', origin_turn_id: 'string' },
|
||||
optional: { workspace: 'string', ticket: 'string', checkpoint_ticket: 'string', question_ticket: 'string', subagent_ticket: 'string', broker_tickets: { items: { ref: 'DshBrokerTicketWire' } }, caller_tools: { items: 'string' }, session_id: 'string', agent_id: 'string', origin_turn_id: 'string' },
|
||||
},
|
||||
UnregisterParams: {
|
||||
strict: true,
|
||||
@@ -355,6 +369,19 @@ export interface DeactivateResult {
|
||||
leaked: string[]
|
||||
}
|
||||
|
||||
export interface DshBrokerParams {
|
||||
owner: OwnerRef
|
||||
ticket: string
|
||||
service: string
|
||||
method: string
|
||||
input: Json
|
||||
}
|
||||
|
||||
export interface DshBrokerTicketWire {
|
||||
service: string
|
||||
ticket: string
|
||||
}
|
||||
|
||||
export interface EmptyParams {}
|
||||
|
||||
export interface EntryRef {
|
||||
@@ -592,6 +619,7 @@ export interface ToolCallParams {
|
||||
checkpoint_ticket?: string
|
||||
question_ticket?: string
|
||||
subagent_ticket?: string
|
||||
broker_tickets?: DshBrokerTicketWire[]
|
||||
caller_tools?: string[]
|
||||
session_id?: string
|
||||
agent_id?: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReviewedLoader, installCompositionLoader } from './dsh/composition.ts'
|
||||
import { DSH_ADAPTERS, type AdapterHost, type DshAdapter } from './dsh/adapters.ts'
|
||||
/**
|
||||
* The Cordis root that plugin fibers run under.
|
||||
*
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
type ToolResultWire,
|
||||
} from './protocol.ts'
|
||||
import { RpcError, type RpcPeer } from './rpc.ts'
|
||||
import { UserQuestionError, PERSONA_PREFIX_SECTION } from './dsh/dsh-message-compat.js'
|
||||
import { UserQuestionError } from './dsh/dsh-message-compat.js'
|
||||
import { explainImportError } from './dsh/resolve-hooks.ts'
|
||||
import compatibility from './dsh/compatibility.json' with { type: 'json' }
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue, ToolOutputError } from './dsh/dsh-tools-compat.js'
|
||||
@@ -68,6 +69,7 @@ export const REFUSED_SERVICES = new Set(compatibility.refused_services)
|
||||
|
||||
/** Services the root provides; `inject` of anything else fails activation. */
|
||||
const PROVIDED_SERVICES = new Set(Object.values(compatibility.provided_services).flat())
|
||||
const CORDIS_SERVICES = new Set(compatibility.provided_services.cordis)
|
||||
|
||||
const ACTIVATE_DEADLINE_MS = 5_000
|
||||
const DISPOSE_DEADLINE_MS = 2_000
|
||||
@@ -134,12 +136,13 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
|
||||
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
interface ToolInvocation {
|
||||
export interface ToolInvocation {
|
||||
owner: OwnerRecord
|
||||
session: object | undefined
|
||||
agent: object | undefined
|
||||
questionTicket: string | undefined
|
||||
subagentTicket: string | undefined
|
||||
brokerTickets: ReadonlyMap<string, string>
|
||||
callerTools: readonly string[]
|
||||
delegated: boolean
|
||||
ticket: string | undefined
|
||||
@@ -165,6 +168,7 @@ export class HostRoot {
|
||||
private readonly rpc: RpcPeer,
|
||||
/** The trust tier this process serves; it activates only owners of that tier. */
|
||||
readonly tier: HostTier,
|
||||
adapters: readonly DshAdapter[] = DSH_ADAPTERS,
|
||||
) {
|
||||
const root: any = new Context()
|
||||
this.root = root
|
||||
@@ -299,28 +303,6 @@ export class HostRoot {
|
||||
set(key: string, value: Json) { return this.api().set(key, value) }
|
||||
delete(key: string) { return this.api().delete(key) }
|
||||
}
|
||||
class SessionsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'sessions') }
|
||||
async flush(session: unknown): Promise<boolean> {
|
||||
const invocation = host.toolInvocations.getStore()
|
||||
const owner: OwnerRecord | undefined = (this.ctx as any)[OWNER]
|
||||
const live = () => invocation && invocation.active && !invocation.signal.aborted
|
||||
&& invocation.owner === owner && owner?.state === 'active' && !owner.disposing
|
||||
&& invocation.ticket !== undefined && session === invocation.session && session !== undefined
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'sessions.flush requires this live tool invocation’s session')
|
||||
const call = invocation!
|
||||
const run = async () => {
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
|
||||
const result = await host.rpc.request<unknown>('session/checkpoint', { owner: call.owner.ref, ticket: call.ticket }, call.signal)
|
||||
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
|
||||
if (typeof result !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid session checkpoint receipt')
|
||||
return result
|
||||
}
|
||||
const result = call.pending.then(run)
|
||||
call.pending = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
}
|
||||
class UserQuestionsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'userQuestions') }
|
||||
async ask(request: { agent?: unknown; questions: unknown; signal?: AbortSignal; wait?: unknown }) {
|
||||
@@ -435,175 +417,52 @@ export class HostRoot {
|
||||
return Promise.race([result, cancelled]).finally(() => signal.removeEventListener('abort', cancel))
|
||||
}
|
||||
}
|
||||
const SPAWN_PROVIDER = Object.freeze({
|
||||
name: 'spawn',
|
||||
inheritsParentContext: false,
|
||||
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true }),
|
||||
})
|
||||
const PERSONA_ORDER = 0
|
||||
class SubagentsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'subagents') }
|
||||
getProvider(name: unknown) { return name === 'spawn' ? SPAWN_PROVIDER : undefined }
|
||||
async startActivation(request: any) {
|
||||
const invocation = host.toolInvocations.getStore()
|
||||
const owner: OwnerRecord | undefined = (this.ctx as any)[OWNER]
|
||||
const plain = (value: unknown, allowed: string[], what: string): Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
||||
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`)
|
||||
const result: Record<string, unknown> = Object.create(null)
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
|
||||
if (typeof key !== 'string' || !allowed.includes(key) || !('value' in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`)
|
||||
if (descriptor.value !== undefined) result[key] = descriptor.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
const input = plain(request, ['provider', 'label', 'childId', 'request', 'signal', 'delivery'], 'activation')
|
||||
if (input.provider !== 'spawn') throw new RpcError(ErrorCode.NotAvailable, 'only the spawn provider is available')
|
||||
const spawn = plain(input.request, ['parent', 'prompt', 'agentOptions'], 'spawn request')
|
||||
const options = spawn.agentOptions === undefined ? {} : plain(spawn.agentOptions, ['model'], 'agentOptions')
|
||||
if (options.model !== undefined && typeof options.model !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'agentOptions.model must be a string')
|
||||
const delivery = input.delivery === undefined ? 'parent' : input.delivery
|
||||
if (delivery !== 'caller' && delivery !== 'parent') throw new RpcError(ErrorCode.InvalidParams, 'delivery must be caller or parent')
|
||||
const childId = input.childId
|
||||
if (typeof childId !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, 'childId must be 1-128 letters, digits, or _.:-')
|
||||
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'prompt must be a non-empty content array')
|
||||
const prompt = spawn.prompt.map((block: unknown) => {
|
||||
const item = plain(block, ['type', 'text'], 'prompt block')
|
||||
if (item.type !== 'text' || typeof item.text !== 'string') throw new RpcError(ErrorCode.NotAvailable, 'only text prompt blocks are supported')
|
||||
return item.text
|
||||
}).join('')
|
||||
if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, 'signal must be an AbortSignal')
|
||||
if (!invocation || !invocation.active || invocation.signal.aborted || invocation.agent === undefined || spawn.parent !== invocation.agent) {
|
||||
throw new RpcError(ErrorCode.NotAvailable, 'startActivation requires the exact live calling agent as parent')
|
||||
}
|
||||
if (invocation.owner !== owner || owner?.state !== 'active' || owner.disposing) throw new RpcError(ErrorCode.NotAvailable, 'subagents service owner is no longer current')
|
||||
if (invocation.delegated || invocation.subagentTicket === undefined) throw new RpcError(ErrorCode.NotAvailable, 'child agents are only available to a root tool invocation')
|
||||
const signal = input.signal === undefined ? invocation.signal : AbortSignal.any([invocation.signal, input.signal as AbortSignal])
|
||||
signal.throwIfAborted()
|
||||
let persona: string | undefined
|
||||
const denied = new Set<string>()
|
||||
let open = true
|
||||
const sealed = () => { if (!open) throw new RpcError(ErrorCode.NotAvailable, 'the child creation window has closed') }
|
||||
const child = Object.freeze({
|
||||
id: childId,
|
||||
ctx: Object.freeze({
|
||||
systemPrompt: Object.freeze({
|
||||
getSectionOrder(name: unknown) {
|
||||
sealed()
|
||||
if (name !== 'DEPLOYMENT_PERSONA_PREFIX') throw new RpcError(ErrorCode.NotAvailable, 'only the deployment persona slot is available')
|
||||
return PERSONA_ORDER
|
||||
},
|
||||
section(section: unknown) {
|
||||
sealed()
|
||||
const value = plain(section, ['name', 'order', 'text'], 'system prompt section')
|
||||
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== 'string') {
|
||||
throw new RpcError(ErrorCode.NotAvailable, 'only the child deployment persona section is supported')
|
||||
}
|
||||
if (persona !== undefined) throw new RpcError(ErrorCode.InvalidParams, 'the child persona is already set')
|
||||
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, 'child persona exceeds 32768 bytes')
|
||||
persona = value.text
|
||||
return () => undefined
|
||||
},
|
||||
}),
|
||||
tools: Object.freeze({
|
||||
restrict(filter: unknown) {
|
||||
sealed()
|
||||
const value = plain(filter, ['deny'], 'tool filter')
|
||||
if (!Array.isArray(value.deny) || value.deny.some(name => typeof name !== 'string' || name === '')) throw new RpcError(ErrorCode.InvalidParams, 'deny must be a list of tool names')
|
||||
for (const name of value.deny as string[]) denied.add(name)
|
||||
return () => undefined
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const listeners = ((host.root as any).events?._hooks?.['agent/created'] ?? [])
|
||||
.filter((hook: any) => !hook.global && hook.ctx?.[OWNER] === owner)
|
||||
.map((hook: any) => hook.callback)
|
||||
try {
|
||||
await Promise.all(listeners.map(async (callback: Function) => callback({ agent: child })))
|
||||
} finally {
|
||||
open = false
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const call = invocation
|
||||
const rpc = (method: string, params: Record<string, unknown>, abort: AbortSignal) =>
|
||||
host.rpc.request<any>(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort)
|
||||
const started = await rpc('subagent/start', {
|
||||
prompt,
|
||||
...(options.model === undefined ? {} : { model: options.model }),
|
||||
...(persona === undefined ? {} : { persona }),
|
||||
denied_tools: [...denied],
|
||||
delivery,
|
||||
}, signal)
|
||||
if (started === null || typeof started !== 'object' || started.child_id !== childId || typeof started.message_id !== 'string') {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid child activation receipt')
|
||||
}
|
||||
let result: Promise<unknown> | undefined
|
||||
let disposed: Promise<void> | undefined
|
||||
return Object.freeze({
|
||||
childId,
|
||||
messageId: started.message_id,
|
||||
get result() {
|
||||
result ??= rpc('subagent/result', {}, call.signal).then((value: any) => {
|
||||
if (value === null || typeof value !== 'object' || typeof value.stop_reason !== 'string' || !Array.isArray(value.output)) {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid child result receipt')
|
||||
}
|
||||
return Object.freeze({
|
||||
stopReason: value.stop_reason,
|
||||
output: Object.freeze(value.output.map((block: any) => Object.freeze({ type: 'text', text: String(block.text) }))),
|
||||
...(value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}),
|
||||
})
|
||||
})
|
||||
return result
|
||||
},
|
||||
dispose() {
|
||||
disposed ??= rpc('subagent/dispose', {}, call.signal).then(() => undefined)
|
||||
return disposed
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
class AgentsShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'agents') }
|
||||
}
|
||||
class SystemPromptShim extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'systemPrompt') }
|
||||
section() { throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context") }
|
||||
getSectionOrder() { throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context") }
|
||||
}
|
||||
Object.freeze(SubagentsShim.prototype)
|
||||
Object.freeze(AgentsShim.prototype)
|
||||
Object.freeze(SystemPromptShim.prototype)
|
||||
Object.freeze(UserQuestionsShim.prototype)
|
||||
Object.freeze(SessionsShim.prototype)
|
||||
Object.freeze(StorageShim.prototype)
|
||||
shimClasses.set('tools', ToolsShim)
|
||||
shimClasses.set('commands', CommandsShim)
|
||||
shimClasses.set('prompt', PromptShim)
|
||||
shimClasses.set('storage', StorageShim)
|
||||
shimClasses.set('sessions', SessionsShim)
|
||||
shimClasses.set('userQuestions', UserQuestionsShim)
|
||||
shimClasses.set('subagents', SubagentsShim)
|
||||
shimClasses.set('agents', AgentsShim)
|
||||
shimClasses.set('systemPrompt', SystemPromptShim)
|
||||
shimClasses.set('skills', SkillsShim)
|
||||
shimClasses.set('avatars', AvatarsShim)
|
||||
shimClasses.set('mcp',McpShim)
|
||||
shimClasses.set('shellHooks',ShellHooksShim)
|
||||
root.plugin(ToolsShim)
|
||||
root.plugin(CommandsShim)
|
||||
root.plugin(PromptShim)
|
||||
root.plugin(StorageShim)
|
||||
root.plugin(SessionsShim)
|
||||
root.plugin(UserQuestionsShim)
|
||||
root.plugin(SubagentsShim)
|
||||
root.plugin(AgentsShim)
|
||||
root.plugin(SystemPromptShim)
|
||||
root.plugin(SkillsShim)
|
||||
root.plugin(AvatarsShim)
|
||||
root.plugin(McpShim)
|
||||
root.plugin(ShellHooksShim)
|
||||
const adapterHost: AdapterHost = {
|
||||
tier,
|
||||
rpc,
|
||||
ownerOf: (ctx) => (ctx as { [OWNER]?: OwnerRecord })[OWNER],
|
||||
liveInvocation: (ctx) => {
|
||||
const invocation = host.toolInvocations.getStore()
|
||||
const owner: OwnerRecord | undefined = (ctx as { [OWNER]?: OwnerRecord })[OWNER]
|
||||
return invocation && invocation.active && !invocation.signal.aborted && owner !== undefined
|
||||
&& invocation.owner === owner && owner.state === 'active' && !owner.disposing
|
||||
? invocation
|
||||
: undefined
|
||||
},
|
||||
log: (level, message, owner) => host.log(level, message, owner),
|
||||
broker: async (ctx, service, method, input) => {
|
||||
const invocation = adapterHost.liveInvocation(ctx)
|
||||
const ticket = invocation?.brokerTickets.get(service)
|
||||
if (invocation === undefined || ticket === undefined) throw new RpcError(ErrorCode.NotAvailable, `the ${String(service).slice(0, 64)} broker is only available to a live tool invocation the core gave a ticket for it`)
|
||||
return rpc.request<unknown>('dsh/broker', { owner: invocation.owner.ref, ticket, service, method, input }, invocation.signal)
|
||||
},
|
||||
}
|
||||
const shims = new Map<string, new (ctx: any) => Service>([
|
||||
['tools', ToolsShim],
|
||||
['commands', CommandsShim],
|
||||
['prompt', PromptShim],
|
||||
['storage', StorageShim],
|
||||
['userQuestions', UserQuestionsShim],
|
||||
['skills', SkillsShim],
|
||||
['avatars', AvatarsShim],
|
||||
['mcp', McpShim],
|
||||
['shellHooks', ShellHooksShim],
|
||||
])
|
||||
for (const adapter of adapters) {
|
||||
if (shims.has(adapter.service)) throw new Error(`DSH adapter for \`${adapter.service}\` duplicates a service the host root already provides`)
|
||||
shims.set(adapter.service, adapter.define(adapterHost))
|
||||
}
|
||||
const mismatched = [
|
||||
...[...shims.keys()].filter((name) => !PROVIDED_SERVICES.has(name)).map((name) => `${name} is installed but not declared in compatibility.json`),
|
||||
...[...PROVIDED_SERVICES].filter((name) => !CORDIS_SERVICES.has(name) && !shims.has(name)).map((name) => `${name} is declared in compatibility.json but has no installed service`),
|
||||
]
|
||||
if (mismatched.length > 0) throw new Error(`DSH provided services disagree with the installed services: ${mismatched.join('; ')}`)
|
||||
for (const [name, shim] of shims) {
|
||||
shimClasses.set(name, shim)
|
||||
root.plugin(shim)
|
||||
}
|
||||
shimClasses.set('loader', ReviewedLoader)
|
||||
installCompositionLoader(root)
|
||||
}
|
||||
@@ -714,7 +573,9 @@ export class HostRoot {
|
||||
const plugin = pickPlugin(module)
|
||||
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => !PROVIDED_SERVICES.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(', ')}, not provided by the Codewhale extension host in this phase`)
|
||||
const owned = missing.filter((name) => REFUSED_SERVICES.has(name))
|
||||
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(', ')}, not provided by the Codewhale extension host in this phase`
|
||||
+ (owned.length > 0 ? `; ${owned.map((name) => `\`${name}\``).join(', ')} belong${owned.length === 1 ? 's' : ''} to the Codewhale core and no reviewed bridge exposes ${owned.length === 1 ? 'it' : 'them'} yet` : ''))
|
||||
}
|
||||
const ownerCtx = this.root.extend({ [OWNER]: owner })
|
||||
const fiber = ownerStorage.run(owner, () => ownerCtx.plugin(plugin, params.config ?? {}))
|
||||
@@ -873,6 +734,7 @@ export class HostRoot {
|
||||
agent,
|
||||
questionTicket: identity?.question_ticket,
|
||||
subagentTicket: identity?.subagent_ticket,
|
||||
brokerTickets: new Map((identity?.broker_tickets ?? []).map(({ service, ticket }) => [service, ticket])),
|
||||
callerTools: Object.freeze([...(identity?.caller_tools ?? [])]),
|
||||
delegated: identity?.agent_id !== undefined,
|
||||
ticket: identity?.checkpoint_ticket,
|
||||
@@ -943,6 +805,7 @@ export class HostRoot {
|
||||
interface InvocationIdentityWire {
|
||||
question_ticket?: string
|
||||
subagent_ticket?: string
|
||||
broker_tickets?: { service: string, ticket: string }[]
|
||||
caller_tools?: string[]
|
||||
checkpoint_ticket?: string
|
||||
session_id?: string
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const rpc = { request: async () => ({}), notify: () => {}, tier: 'plugin' }
|
||||
let HostRoot
|
||||
let Service
|
||||
let DSH_ADAPTERS
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-adapters-'))
|
||||
const outfile = join(dir, 'root.mjs')
|
||||
await build({
|
||||
entryPoints: [join(here, '../src/root.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
packages: 'external',
|
||||
absWorkingDir: join(here, '..'),
|
||||
logLevel: 'silent',
|
||||
})
|
||||
const adapters = join(dir, 'adapters.mjs')
|
||||
await build({
|
||||
entryPoints: [join(here, '../src/dsh/adapters.ts')],
|
||||
outfile: adapters,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
packages: 'external',
|
||||
absWorkingDir: join(here, '..'),
|
||||
logLevel: 'silent',
|
||||
})
|
||||
;({ HostRoot } = await import(pathToFileURL(outfile).href))
|
||||
;({ DSH_ADAPTERS } = await import(pathToFileURL(adapters).href))
|
||||
;({ Service } = await import('@deepseek-ai/cordis'))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
const adapterFor = (service) => ({ service, define: () => class extends Service { constructor(ctx) { super(ctx, service) } } })
|
||||
|
||||
test('the shipped adapter table holds the reviewed adapters, and the root still builds', () => {
|
||||
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents'])
|
||||
assert.ok(new HostRoot(rpc, 'plugin'))
|
||||
assert.ok(new HostRoot(rpc, 'builtin'))
|
||||
})
|
||||
|
||||
test('an adapter for a service compatibility.json does not declare is refused at construction', () => {
|
||||
assert.throws(() => new HostRoot(rpc, 'plugin', [...DSH_ADAPTERS, adapterFor('llm')]), /llm is installed but not declared in compatibility\.json/)
|
||||
})
|
||||
|
||||
test('an adapter may not replace a service the root already provides', () => {
|
||||
assert.throws(() => new HostRoot(rpc, 'plugin', [adapterFor('storage')]), /duplicates a service the host root already provides/)
|
||||
assert.throws(() => new HostRoot(rpc, 'plugin', [...DSH_ADAPTERS, adapterFor('sessions')]), /duplicates a service/)
|
||||
})
|
||||
|
||||
test('the adapter broker refuses locally, without sending, when no live invocation holds a ticket for the service', async () => {
|
||||
const sent = []
|
||||
const peer = { ...rpc, request: async (method, params) => { sent.push([method, params]); return {} } }
|
||||
let host
|
||||
const capture = { service: 'llm', define: (adapterHost) => { host = adapterHost; return adapterFor('llm').define() } }
|
||||
assert.throws(() => new HostRoot(peer, 'plugin', [capture]), /not declared in compatibility\.json/)
|
||||
for (const service of ['workspace', 'llm', 'vault']) {
|
||||
await assert.rejects(host.broker({}, service, 'read', {}), (error) => error.code === -32001 && /broker is only available to a live tool invocation/.test(error.message))
|
||||
}
|
||||
assert.deepEqual(sent, [])
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { activate, startHost } from './harness.mjs'
|
||||
import { ErrorCode } from '../dist/protocol.mjs'
|
||||
|
||||
const PLUGIN = `
|
||||
export const inject = ['tools', 'subagents', 'agents']
|
||||
export function apply(ctx) {
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: 'Reviewer persona' })
|
||||
agent.ctx.tools.restrict({ deny: ['exec_shell'] })
|
||||
})
|
||||
ctx.tools.register({ name: 'team_start', description: 'Start one child agent.', parameters: { type: 'object' },
|
||||
async execute(input, exec) {
|
||||
try {
|
||||
const parent = input.forged ? { id: exec.agent?.id } : exec.agent
|
||||
const handle = await ctx.subagents.startActivation({
|
||||
provider: 'spawn', childId: input.childId ?? 'reviewer-1', delivery: 'caller',
|
||||
request: { parent, prompt: [{ type: 'text', text: 'Review the diff' }] },
|
||||
})
|
||||
const result = await handle.result
|
||||
await handle.dispose()
|
||||
return { messageId: handle.messageId, stopReason: result.stopReason, text: result.output.map(block => block.text).join('') }
|
||||
} catch (error) {
|
||||
return { refused: error.code ?? String(error) }
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
test('the agents adapters serve subagents and agents through the reviewed adapter table', async (t) => {
|
||||
const host = await startHost({
|
||||
subagent: (params, _id, method) => method === 'subagent/start'
|
||||
? { child_id: params.child_id, message_id: 'message-1' }
|
||||
: method === 'subagent/result'
|
||||
? { stop_reason: 'end_turn', output: [{ type: 'text', text: 'looks good' }], omitted_blocks: 0 }
|
||||
: {},
|
||||
})
|
||||
t.after(() => host.stop())
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cw-agents-'))
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||||
const entry = join(dir, 'index.mjs')
|
||||
writeFileSync(entry, PLUGIN)
|
||||
const { ref, result } = await activate(host, 'agent-team', entry)
|
||||
assert.deepEqual(result, { status: 'ok', tools: ['team_start'], commands: [] })
|
||||
const handle = host.registry.find(row => row.op === 'register' && row.spec.name === 'team_start').handle
|
||||
const call = (input, identity) => host.call('tool/call', { handle, call_id: 'team', input, deadline_ms: 5000, ...identity })
|
||||
const root = { session_id: 'session-root', workspace: '/workspace/team', subagent_ticket: 'subagent-ticket-1' }
|
||||
|
||||
assert.deepEqual((await call({}, root)).structured, { messageId: 'message-1', stopReason: 'end_turn', text: 'looks good' })
|
||||
assert.deepEqual(host.subagents.map(row => row.method), ['subagent/start', 'subagent/result', 'subagent/dispose'])
|
||||
const [start] = host.subagents
|
||||
assert.deepEqual(start.owner, ref)
|
||||
assert.equal(start.ticket, 'subagent-ticket-1')
|
||||
assert.equal(start.child_id, 'reviewer-1')
|
||||
assert.equal(start.prompt, 'Review the diff')
|
||||
assert.equal(start.persona, 'Reviewer persona')
|
||||
assert.deepEqual(start.denied_tools, ['exec_shell'])
|
||||
assert.equal(start.delivery, 'caller')
|
||||
assert.ok(host.subagents.every(row => row.ticket === 'subagent-ticket-1' && row.child_id === 'reviewer-1'))
|
||||
|
||||
const before = host.subagents.length
|
||||
assert.deepEqual((await call({}, { ...root, agent_id: 'agent-child' })).structured, { refused: ErrorCode.NotAvailable }, 'a delegated caller cannot nest child agents')
|
||||
assert.deepEqual((await call({}, { session_id: 'session-root', workspace: '/workspace/team' })).structured, { refused: ErrorCode.NotAvailable }, 'no subagent ticket, no child')
|
||||
assert.deepEqual((await call({ forged: true }, root)).structured, { refused: ErrorCode.NotAvailable }, 'a forged parent is not the live caller')
|
||||
assert.deepEqual((await call({ childId: 'bad id' }, root)).structured, { refused: ErrorCode.InvalidParams })
|
||||
assert.equal(host.subagents.length, before, 'refused activations never reach the core')
|
||||
})
|
||||
@@ -51,7 +51,7 @@ export const LIMITS = { max_frame: 32 * 1024 * 1024, max_inflight: 256, dispose_
|
||||
* cancelled is not answered. Requests are recorded in `coreCalls`, the host's
|
||||
* `$/cancel` ids in `cancels`.
|
||||
*/
|
||||
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, checkpoint, userQuestions, compiledHost, cwd } = {}) {
|
||||
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, checkpoint, userQuestions, subagent, compiledHost, cwd } = {}) {
|
||||
const started = performance.now()
|
||||
// `ownGroup` spawns the host as a process-group leader and tells it so, as
|
||||
// the Rust core does on Unix.
|
||||
@@ -73,6 +73,7 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin',
|
||||
coreCalls: [],
|
||||
checkpoints: [],
|
||||
questions: [],
|
||||
subagents: [],
|
||||
cancels: [],
|
||||
registry: [],
|
||||
hello: null,
|
||||
@@ -146,11 +147,15 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin',
|
||||
break
|
||||
case 'session/checkpoint':
|
||||
case 'user-questions/ask':
|
||||
case 'subagent/start':
|
||||
case 'subagent/result':
|
||||
case 'subagent/dispose':
|
||||
case 'core/call': {
|
||||
const handler = message.method === 'session/checkpoint' ? checkpoint : message.method === 'user-questions/ask' ? userQuestions : coreCall
|
||||
const records = message.method === 'session/checkpoint' ? host.checkpoints : message.method === 'user-questions/ask' ? host.questions : host.coreCalls
|
||||
records.push({ id: message.id, ...message.params })
|
||||
const answer = handler ? handler(message.params, message.id) : { error: { code: -32002, message: 'no handler for ' + message.method } }
|
||||
const childRequest = message.method.startsWith('subagent/')
|
||||
const handler = message.method === 'session/checkpoint' ? checkpoint : message.method === 'user-questions/ask' ? userQuestions : childRequest ? subagent : coreCall
|
||||
const records = message.method === 'session/checkpoint' ? host.checkpoints : message.method === 'user-questions/ask' ? host.questions : childRequest ? host.subagents : host.coreCalls
|
||||
records.push({ id: message.id, ...(childRequest ? { method: message.method } : {}), ...message.params })
|
||||
const answer = handler ? handler(message.params, message.id, message.method) : { error: { code: -32002, message: 'no handler for ' + message.method } }
|
||||
const id = message.id
|
||||
Promise.resolve(answer).then((value) => {
|
||||
if (host.cancels.includes(id) || child.exitCode !== null || stopping) return
|
||||
|
||||
@@ -174,6 +174,7 @@ test('injecting a service the host does not provide fails with its name', async
|
||||
const { result } = await activate(host, 'needs-secrets', plugin.entry)
|
||||
assert.equal(result.status, 'failed')
|
||||
assert.match(result.diagnostic, /requires `secrets`/)
|
||||
assert.doesNotMatch(result.diagnostic, /belongs? to the Codewhale core/)
|
||||
const provided = tempPlugin("export const inject = ['shellHooks', 'sessions', 'userQuestions']; export function apply(ctx) { if (!ctx.shellHooks || typeof ctx.sessions.flush !== 'function' || typeof ctx.userQuestions.ask !== 'function') throw new Error('missing provided service') }\n")
|
||||
t.after(() => provided.cleanup())
|
||||
const available = await activate(host, 'needs-shell-hooks', provided.entry)
|
||||
@@ -187,6 +188,7 @@ export function apply() { createUserMessage({ content: [], source: { kind: 'fixt
|
||||
const refused = (await activate(host, `pure-needs-${service}`, pure.entry)).result
|
||||
assert.equal(refused.status, 'failed')
|
||||
assert.ok(refused.diagnostic.includes('requires `' + service + '`'), refused.diagnostic)
|
||||
assert.match(refused.diagnostic, /`llm` belongs to the Codewhale core and no reviewed bridge exposes it yet/)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let credentialsAdapter
|
||||
let brokeredFetch
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-network-'))
|
||||
const outfile = join(dir, 'network.mjs')
|
||||
await build({ entryPoints: [join(here, '../src/dsh/network.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
;({ credentialsAdapter, brokeredFetch } = await import(pathToFileURL(outfile).href))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function brokerHost(answers) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
host: {
|
||||
tier: 'plugin',
|
||||
broker: async (ctx, service, method, input) => {
|
||||
calls.push({ service, method, input })
|
||||
const answer = answers[method]
|
||||
if (answer instanceof Error) throw answer
|
||||
return typeof answer === 'function' ? answer(input) : answer
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('credentials resolve to an opaque handle and go only to the network broker', async () => {
|
||||
const { host, calls } = brokerHost({
|
||||
'credentials.resolve': { ref: 'KEY', value: 'codewhale-credential:KEY' },
|
||||
'credentials.describe': { ref: 'KEY', set: true },
|
||||
'credentials.set': { ref: 'KEY', set: true },
|
||||
})
|
||||
const Credentials = credentialsAdapter.define(host)
|
||||
const service = Object.create(Credentials.prototype)
|
||||
service.ctx = {}
|
||||
assert.deepEqual(await service.resolve('KEY'), { value: 'codewhale-credential:KEY' })
|
||||
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true })
|
||||
await service.set('KEY', 'sk-secret')
|
||||
assert.deepEqual(calls.map((c) => [c.service, c.method]), [
|
||||
['network', 'credentials.resolve'], ['network', 'credentials.describe'], ['network', 'credentials.set'],
|
||||
])
|
||||
await assert.rejects(service.resolve(''), /non-empty/)
|
||||
})
|
||||
|
||||
test('a resolve answer that is not a handle is refused rather than passed on', async () => {
|
||||
const { host } = brokerHost({ 'credentials.resolve': { value: 'sk-raw' } })
|
||||
const service = Object.create(credentialsAdapter.define(host).prototype)
|
||||
service.ctx = {}
|
||||
await assert.rejects(service.resolve('KEY'), /invalid credential handle/)
|
||||
const unset = Object.create(credentialsAdapter.define(brokerHost({ 'credentials.resolve': { value: null } }).host).prototype)
|
||||
unset.ctx = {}
|
||||
assert.equal(await unset.resolve('KEY'), undefined)
|
||||
})
|
||||
|
||||
test('brokeredFetch sends a typed fetch and rebuilds a Response from the broker answer', async () => {
|
||||
const { host, calls } = brokerHost({
|
||||
fetch: {
|
||||
status: 201, url: 'https://api.example.com/v1/x', redirected: true,
|
||||
headers: [['content-type', 'application/json']],
|
||||
body_base64: Buffer.from('{"ok":true}').toString('base64'),
|
||||
},
|
||||
})
|
||||
const response = await brokeredFetch(host, {}, 'https://api.example.com/v1/x', {
|
||||
method: 'POST', headers: { authorization: 'Bearer codewhale-credential:KEY' }, body: 'hi',
|
||||
})
|
||||
assert.equal(response.status, 201)
|
||||
assert.equal(response.url, 'https://api.example.com/v1/x')
|
||||
assert.equal(response.redirected, true)
|
||||
assert.deepEqual(await response.json(), { ok: true })
|
||||
assert.equal(calls[0].service, 'network')
|
||||
assert.equal(calls[0].method, 'fetch')
|
||||
assert.deepEqual(calls[0].input.headers, [['authorization', 'Bearer codewhale-credential:KEY']])
|
||||
assert.equal(Buffer.from(calls[0].input.body_base64, 'base64').toString(), 'hi')
|
||||
})
|
||||
|
||||
test('a broker refusal reaches the plugin as the refusal', async () => {
|
||||
const { host } = brokerHost({ fetch: new Error('egress refused: `evil.example` is not one of this plugin\'s reviewed network hosts') })
|
||||
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /egress refused/)
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let sessionsAdapter
|
||||
let systemPromptAdapter
|
||||
let Context
|
||||
let ErrorCode
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-session-prompt-'))
|
||||
const bundle = async (entry, name) => {
|
||||
const outfile = join(dir, name)
|
||||
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
return import(pathToFileURL(outfile).href)
|
||||
}
|
||||
;({ sessionsAdapter, systemPromptAdapter } = await bundle('../src/dsh/session-prompt.ts', 'session-prompt.mjs'))
|
||||
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
|
||||
;({ Context } = await import('@deepseek-ai/cordis'))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
function flushHarness(invocation, requests) {
|
||||
const session = {}
|
||||
const host = {
|
||||
tier: 'plugin',
|
||||
rpc: { request: async (method, params) => { requests.push({ method, params }); return true }, notify() {} },
|
||||
ownerOf: () => invocation?.owner,
|
||||
liveInvocation: () => invocation,
|
||||
log() {},
|
||||
}
|
||||
const ctx = new Context()
|
||||
ctx.plugin(sessionsAdapter.define(host))
|
||||
return { ctx, session }
|
||||
}
|
||||
|
||||
const live = (extra = {}) => {
|
||||
const session = {}
|
||||
return { owner: { ref: { plugin_id: 'p' } }, session, ticket: 'ticket', signal: new AbortController().signal, pending: Promise.resolve(), ...extra }
|
||||
}
|
||||
|
||||
test('sessions.flush refuses a live invocation that carries no checkpoint ticket', async () => {
|
||||
const requests = []
|
||||
const invocation = live({ ticket: undefined })
|
||||
const { ctx } = flushHarness(invocation, requests)
|
||||
await settle()
|
||||
await assert.rejects(ctx.sessions.flush(invocation.session), (error) => error.code === ErrorCode.NotAvailable)
|
||||
assert.equal(requests.length, 0)
|
||||
})
|
||||
|
||||
test('sessions.flush refuses a session other than the invocation session and sends nothing', async () => {
|
||||
const requests = []
|
||||
const invocation = live()
|
||||
const { ctx } = flushHarness(invocation, requests)
|
||||
await settle()
|
||||
await assert.rejects(ctx.sessions.flush({}), (error) => error.code === ErrorCode.NotAvailable)
|
||||
await assert.rejects(ctx.sessions.flush(undefined), (error) => error.code === ErrorCode.NotAvailable)
|
||||
assert.equal(requests.length, 0)
|
||||
})
|
||||
|
||||
test('sessions.flush sends the exact owner and ticket, and a stale invocation is refused before the receipt returns', async () => {
|
||||
const requests = []
|
||||
const invocation = live()
|
||||
let current = invocation
|
||||
const host = { tier: 'plugin', rpc: { request: async (method, params) => { requests.push({ method, params }); current = undefined; return true }, notify() {} }, ownerOf: () => invocation.owner, liveInvocation: () => current, log() {} }
|
||||
const ctx = new Context()
|
||||
ctx.plugin(sessionsAdapter.define(host))
|
||||
await settle()
|
||||
await assert.rejects(ctx.sessions.flush(invocation.session), (error) => error.code === ErrorCode.NotAvailable)
|
||||
assert.deepEqual(requests, [{ method: 'session/checkpoint', params: { owner: invocation.owner.ref, ticket: 'ticket' } }])
|
||||
})
|
||||
|
||||
test('systemPrompt stays child-only: the root context refuses sections', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.plugin(systemPromptAdapter.define({}))
|
||||
await settle()
|
||||
assert.throws(() => ctx.systemPrompt.section({ name: 'x', text: 'y' }), (error) => error.code === ErrorCode.NotAvailable)
|
||||
assert.throws(() => ctx.systemPrompt.getSectionOrder('X'), (error) => error.code === ErrorCode.NotAvailable)
|
||||
})
|
||||
@@ -220,9 +220,22 @@ impl CodewhaleClient {
|
||||
Some(level) if thinking_capable && supports_adaptive => {
|
||||
body["thinking"] = json!({ "type": "adaptive" });
|
||||
if !is_minimax {
|
||||
let row_lists_xhigh = self
|
||||
.route_facts
|
||||
.as_ref()
|
||||
.filter(|facts| facts.wire_model_id == request.model)
|
||||
.is_some_and(|facts| {
|
||||
facts.reasoning_options.iter().any(|option| {
|
||||
option.get("type").and_then(Value::as_str) == Some("effort")
|
||||
&& option.get("values").and_then(Value::as_array).is_some_and(
|
||||
|values| values.iter().any(|v| v.as_str() == Some("xhigh")),
|
||||
)
|
||||
})
|
||||
});
|
||||
let mapped = match level {
|
||||
"low" | "minimal" => "low",
|
||||
"medium" | "mid" => "medium",
|
||||
"xhigh" if row_lists_xhigh => "xhigh",
|
||||
"max" | "xhigh" | "highest" => "max",
|
||||
_ => "high",
|
||||
};
|
||||
@@ -797,7 +810,18 @@ fn anthropic_tool_choice(tool_choice: &Value, model: &str) -> Value {
|
||||
Some("none") => json!({ "type": "none" }),
|
||||
Some("any" | "required") => json!({ "type": "any" }),
|
||||
Some(name) => json!({ "type": "tool", "name": to_api_tool_name(name) }),
|
||||
None => tool_choice.clone(),
|
||||
None => match forced_tool_name(tool_choice) {
|
||||
Some(name) => {
|
||||
let mut choice = tool_choice.clone();
|
||||
choice["type"] = json!("tool");
|
||||
choice["name"] = json!(to_api_tool_name(name));
|
||||
if let Some(object) = choice.as_object_mut() {
|
||||
object.remove("function");
|
||||
}
|
||||
choice
|
||||
}
|
||||
None => tool_choice.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,6 +1513,94 @@ mod tests {
|
||||
CodewhaleClient::new(&config).expect("Model Studio Messages client constructs")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_tool_names_stay_inside_the_anthropic_pattern_and_decode_back() {
|
||||
let client = test_client();
|
||||
let mut request = request_with("claude-sonnet-4-6", None, None, None);
|
||||
let tool = |name: &str| Tool {
|
||||
tool_type: None,
|
||||
name: name.to_string(),
|
||||
description: "d".to_string(),
|
||||
input_schema: json!({"type": "object", "additionalProperties": false}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
};
|
||||
request.tools = Some(vec![tool("read_file"), tool("web.run")]);
|
||||
request.tool_choice = Some(json!({"type": "tool", "name": "web.run"}));
|
||||
request.messages.push(Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
id: "toolu_w".to_string(),
|
||||
name: "web.run".to_string(),
|
||||
input: json!({}),
|
||||
caller: None,
|
||||
thought_signature: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let body = client.build_anthropic_body(&request, true);
|
||||
let valid = |name: &str| {
|
||||
(1..=128).contains(&name.len())
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
};
|
||||
let mut sent = Vec::new();
|
||||
for tool in body["tools"].as_array().expect("tools") {
|
||||
sent.push(tool["name"].as_str().expect("tool name").to_string());
|
||||
}
|
||||
sent.push(
|
||||
body.pointer("/tool_choice/name")
|
||||
.and_then(Value::as_str)
|
||||
.expect("forced tool name")
|
||||
.to_string(),
|
||||
);
|
||||
for message in body["messages"].as_array().expect("messages") {
|
||||
for block in message["content"].as_array().into_iter().flatten() {
|
||||
if block["type"] == "tool_use" {
|
||||
sent.push(block["name"].as_str().expect("tool_use name").to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(sent.iter().all(|name| valid(name)), "{sent:?}");
|
||||
assert!(sent.iter().any(|name| name != "read_file"), "{sent:?}");
|
||||
|
||||
let wire = to_api_tool_name("web.run");
|
||||
let message = decode_anthropic_message(json!({
|
||||
"id": "m", "type": "message", "role": "assistant",
|
||||
"model": "claude-sonnet-4-6", "stop_reason": "tool_use",
|
||||
"content": [{"type": "tool_use", "id": "toolu_w", "name": wire, "input": {}}],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}))
|
||||
.unwrap();
|
||||
let ContentBlock::ToolUse { name, .. } = &message.content[0] else {
|
||||
panic!("expected tool_use")
|
||||
};
|
||||
assert_eq!(name, "web.run");
|
||||
|
||||
let event = convert_anthropic_sse_data(
|
||||
&json!({
|
||||
"type": "content_block_start", "index": 0,
|
||||
"content_block": {"type": "tool_use", "id": "toolu_w", "name": wire, "input": {}},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let StreamEvent::ContentBlockStart {
|
||||
content_block: codewhale_models::ContentBlockStart::ToolUse { name, .. },
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected tool_use start")
|
||||
};
|
||||
assert_eq!(name, "web.run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_keeps_native_cache_control_on_system_and_tools() {
|
||||
let client = test_client();
|
||||
@@ -1559,6 +1671,36 @@ mod tests {
|
||||
Some("max")
|
||||
);
|
||||
|
||||
let offering = |model: &str| {
|
||||
client
|
||||
.catalog_facts_for_model(model)
|
||||
.unwrap_or_else(|| panic!("bundled catalog carries {model}"))
|
||||
};
|
||||
let mut xhigh_client = test_client();
|
||||
xhigh_client.route_facts = Some(offering("claude-opus-4-8"));
|
||||
let body = xhigh_client.build_anthropic_body(
|
||||
&request_with("claude-opus-4-8", Some("xhigh"), None, None),
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/output_config/effort")
|
||||
.and_then(Value::as_str),
|
||||
Some("xhigh"),
|
||||
"a row that lists xhigh is sent xhigh: {body}"
|
||||
);
|
||||
let mut no_xhigh_client = test_client();
|
||||
no_xhigh_client.route_facts = Some(offering("claude-sonnet-4-6"));
|
||||
let body = no_xhigh_client.build_anthropic_body(
|
||||
&request_with("claude-sonnet-4-6", Some("xhigh"), None, None),
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/output_config/effort")
|
||||
.and_then(Value::as_str),
|
||||
Some("max"),
|
||||
"a row without xhigh keeps the max mapping: {body}"
|
||||
);
|
||||
|
||||
let body = client.build_anthropic_body(
|
||||
&request_with("claude-sonnet-4-6", Some("off"), None, None),
|
||||
true,
|
||||
@@ -1800,6 +1942,18 @@ mod tests {
|
||||
Some("disabled"),
|
||||
"{body}"
|
||||
);
|
||||
|
||||
let mut preview_client = modelstudio_test_client(
|
||||
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
preview_client.route_facts = preview_client.catalog_facts_for_model("qwen4-max-preview");
|
||||
for effort in [None, Some("high"), Some("max"), Some("off")] {
|
||||
let mut request = request_with("qwen4-max-preview", effort, None, None);
|
||||
request.max_tokens = 64_000;
|
||||
let body = preview_client.build_anthropic_body(&request, true);
|
||||
assert!(body.get("thinking").is_none(), "{effort:?}: {body}");
|
||||
assert!(body.get("output_config").is_none(), "{effort:?}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5443,6 +5443,7 @@ mod alias_thinking_detection_tests {
|
||||
"foo-reasoner",
|
||||
"new-model-reasoning",
|
||||
"acme-reasoner-v2",
|
||||
"qwen4-max-preview",
|
||||
] {
|
||||
assert!(
|
||||
!should_replay_reasoning_content_for_provider_on_route(
|
||||
|
||||
@@ -41,6 +41,60 @@ pub trait CommandCatalog: Send + Sync {
|
||||
fn answers_to(&self, name: &str) -> bool;
|
||||
fn builtins(&self) -> Vec<BuiltinCommand>;
|
||||
fn user_commands(&self, workspace: &Path) -> Vec<UserCommand>;
|
||||
/// Whether this host executes `name` remotely at all. Checked before any
|
||||
/// thread state is assembled, so a name outside the remote surface is
|
||||
/// refused exactly like a name no host knows. `false` is the default: a
|
||||
/// host opts in one command at a time, and widening this must widen
|
||||
/// [`Self::execute_builtin`] too.
|
||||
fn serves_builtin(&self, name: &str) -> bool {
|
||||
let _ = name;
|
||||
false
|
||||
}
|
||||
/// Execute one built-in command against a host snapshot. `None` means
|
||||
/// this host does not implement remote execution, or does not answer to
|
||||
/// the name; `Some` carries the command's own answer.
|
||||
fn execute_builtin(
|
||||
&self,
|
||||
name: &str,
|
||||
args: Option<&str>,
|
||||
host: &BuiltinCommandHostSnapshot,
|
||||
) -> Option<BuiltinCommandExecution> {
|
||||
let _ = (name, args, host);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The thread state a remote built-in execution reads.
|
||||
///
|
||||
/// Engine-side so the app-server can assemble it from thread state and the
|
||||
/// terminal host can interpret it without either depending on the other. The
|
||||
/// projections are the command contract's own data-only types: the host maps
|
||||
/// them into the facet envelope, and the capability mask each portable
|
||||
/// handler declares decides which commands this snapshot can honestly serve.
|
||||
pub struct BuiltinCommandHostSnapshot {
|
||||
pub mode: codewhale_command_contract::types::CommandMode,
|
||||
/// The host's own label for `mode` ("WORK", "PLAN"), the same string the
|
||||
/// terminal footer shows.
|
||||
pub mode_label: String,
|
||||
pub locale: codewhale_localization::Locale,
|
||||
pub model: String,
|
||||
pub system_prompt: codewhale_command_contract::facets::DebugSystemPrompt,
|
||||
/// Published usage telemetry and the host's cost breakdown for the
|
||||
/// thread — the complete data source the portable `/tokens` and `/cost`
|
||||
/// renderers consume.
|
||||
pub tokens: codewhale_command_contract::facets::DebugTokenProjection,
|
||||
}
|
||||
|
||||
/// One remote built-in execution's outcome.
|
||||
pub struct BuiltinCommandExecution {
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub is_error: bool,
|
||||
/// `false` when the command also asked the terminal host for an action —
|
||||
/// the text is still what the command answered, but no API client can
|
||||
/// perform that action (a balance fetch, a snapshot restore) on the
|
||||
/// host's behalf.
|
||||
pub text_only: bool,
|
||||
}
|
||||
|
||||
static CATALOG: OnceLock<Arc<dyn CommandCatalog>> = OnceLock::new();
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
//! tool's `core/call`s run beside it, not through the lock.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -75,9 +76,10 @@ use serde_json::{Value, json};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::ManagerShared;
|
||||
use super::dsh_broker::{self, Arm, BrokerCall, BrokerRefusal};
|
||||
use super::protocol::{
|
||||
ContentBlockWire, CoreCallParams, RpcErrorWire, SessionCheckpointParams, ToolResultWire,
|
||||
UserQuestionParams, error_code,
|
||||
ContentBlockWire, CoreCallParams, DshBrokerParams, DshBrokerTicketWire, DshService,
|
||||
RpcErrorWire, SessionCheckpointParams, ToolResultWire, UserQuestionParams, error_code,
|
||||
};
|
||||
use super::supervisor::HostRequestContext;
|
||||
use super::ticket::{Grant, Presented, Ticket, TicketKind};
|
||||
@@ -263,6 +265,8 @@ pub struct Invocation {
|
||||
calls: CodemodeInvoker,
|
||||
gate: NestedCallGate,
|
||||
root_caller: Option<SessionCheckpointCaller>,
|
||||
broker: Option<BrokerScope>,
|
||||
broker_core_calls: AtomicU32,
|
||||
tickets: Vec<Ticket>,
|
||||
caller_tools: Vec<String>,
|
||||
activations: Mutex<
|
||||
@@ -280,6 +284,103 @@ pub struct Invocation {
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
struct BrokerScope {
|
||||
session_id: String,
|
||||
context: Arc<ToolContext>,
|
||||
}
|
||||
|
||||
pub struct BrokerContext {
|
||||
invocation: Arc<Invocation>,
|
||||
tool: Arc<ToolContext>,
|
||||
service: DshService,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl BrokerContext {
|
||||
#[must_use]
|
||||
pub fn tool(&self) -> &ToolContext {
|
||||
&self.tool
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cancel(&self) -> &CancellationToken {
|
||||
&self.cancel
|
||||
}
|
||||
|
||||
pub async fn core_call(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
) -> Result<ToolCallResponse, BrokerRefusal> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(BrokerRefusal::nested(
|
||||
self.service,
|
||||
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.invocation
|
||||
.broker_core_calls
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
>= MAX_CALLS_PER_INVOCATION
|
||||
{
|
||||
return Err(BrokerRefusal::nested(
|
||||
self.service,
|
||||
NestedFailure::Rejected {
|
||||
decision: NestedDecision::Refused,
|
||||
message: format!(
|
||||
"an invocation's brokers may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
|
||||
),
|
||||
},
|
||||
));
|
||||
}
|
||||
self.invocation
|
||||
.calls
|
||||
.call(name.to_string(), input, Some(&self.cancel))
|
||||
.await
|
||||
.map_err(|failure| BrokerRefusal::nested(self.service, failure))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl BrokerContext {
|
||||
pub fn detached_for_test(owner: &OwnerRef, service: DshService) -> Self {
|
||||
let tool = ToolContext::new(std::path::PathBuf::from("."));
|
||||
let gate = NestedCallGate::admitting_for_test();
|
||||
let calls = CodemodeInvoker::for_extension(
|
||||
Vec::new(),
|
||||
tool.clone(),
|
||||
gate.clone(),
|
||||
String::new(),
|
||||
|_, _, _| None,
|
||||
);
|
||||
let invocation = Arc::new(Invocation {
|
||||
tier: HostTier::Plugin,
|
||||
host_generation: 0,
|
||||
owner: owner.clone(),
|
||||
scope: None,
|
||||
plugins: None,
|
||||
content_hash: String::new(),
|
||||
calls,
|
||||
gate,
|
||||
root_caller: None,
|
||||
broker: None,
|
||||
broker_core_calls: AtomicU32::new(0),
|
||||
tickets: Vec::new(),
|
||||
caller_tools: Vec::new(),
|
||||
activations: Mutex::new(HashMap::new()),
|
||||
session_call_lock: tokio::sync::Mutex::new(()),
|
||||
cancel: CancellationToken::new(),
|
||||
});
|
||||
Self {
|
||||
invocation,
|
||||
tool: Arc::new(tool),
|
||||
service,
|
||||
cancel: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tickets and invocations of one manager.
|
||||
#[derive(Default)]
|
||||
pub struct CoreCalls {
|
||||
@@ -405,6 +506,45 @@ impl CoreCalls {
|
||||
uses: MAX_CALLS_PER_INVOCATION,
|
||||
})
|
||||
});
|
||||
let broker_session = context
|
||||
.execution
|
||||
.session_objects
|
||||
.as_ref()
|
||||
.map(|session| session.session_id.clone())
|
||||
.filter(|id| !id.is_empty());
|
||||
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
|
||||
.iter()
|
||||
.flat_map(|session| {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| dsh_broker::granted(*service))
|
||||
.map(move |service| {
|
||||
(
|
||||
service,
|
||||
self.tickets.mint(Grant {
|
||||
kind: TicketKind::Invocation,
|
||||
tier,
|
||||
host_generation,
|
||||
owner: owner.clone(),
|
||||
method: dsh_broker::ticket_method(service),
|
||||
target: json!({ "call_id": call_id, "session_id": session }),
|
||||
ttl: TICKET_TTL,
|
||||
uses: MAX_CALLS_PER_INVOCATION,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let broker = broker_session
|
||||
.filter(|_| !broker_tickets.is_empty())
|
||||
.map(|session_id| {
|
||||
let mut context = context.clone();
|
||||
context.execution.nested_call_gate = None;
|
||||
BrokerScope {
|
||||
session_id,
|
||||
context: Arc::new(context),
|
||||
}
|
||||
});
|
||||
let caller_tools = specs
|
||||
.iter()
|
||||
.map(|spec| spec.name().to_string())
|
||||
@@ -425,10 +565,13 @@ impl CoreCalls {
|
||||
calls,
|
||||
gate: gate.clone(),
|
||||
root_caller,
|
||||
broker,
|
||||
broker_core_calls: AtomicU32::new(0),
|
||||
tickets: std::iter::once(ticket.clone())
|
||||
.chain(checkpoint_ticket.iter().cloned())
|
||||
.chain(question_ticket.iter().cloned())
|
||||
.chain(subagent_ticket.iter().cloned())
|
||||
.chain(broker_tickets.iter().map(|(_, ticket)| ticket.clone()))
|
||||
.collect(),
|
||||
caller_tools,
|
||||
activations: Mutex::new(HashMap::new()),
|
||||
@@ -457,7 +600,14 @@ impl CoreCalls {
|
||||
Arc::clone(&invocation),
|
||||
);
|
||||
}
|
||||
for (_, broker_ticket) in &broker_tickets {
|
||||
self.invocations
|
||||
.lock()
|
||||
.expect("invocations lock")
|
||||
.insert(broker_ticket.expose().to_string(), Arc::clone(&invocation));
|
||||
}
|
||||
Some(InvocationGuard {
|
||||
broker_tickets,
|
||||
question_ticket,
|
||||
subagent_ticket,
|
||||
checkpoint_ticket,
|
||||
@@ -1063,6 +1213,115 @@ impl CoreCalls {
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn serve_broker(
|
||||
&self,
|
||||
shared: &ManagerShared,
|
||||
tier: HostTier,
|
||||
host_generation: u64,
|
||||
params: DshBrokerParams,
|
||||
cx: HostRequestContext,
|
||||
) -> Result<Value, RpcErrorWire> {
|
||||
self.serve_broker_in(dsh_broker::TABLE, shared, tier, host_generation, params, cx)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn serve_broker_in(
|
||||
&self,
|
||||
table: &[(DshService, Arm)],
|
||||
shared: &ManagerShared,
|
||||
tier: HostTier,
|
||||
host_generation: u64,
|
||||
params: DshBrokerParams,
|
||||
cx: HostRequestContext,
|
||||
) -> Result<Value, RpcErrorWire> {
|
||||
let refused = |message: String| {
|
||||
dsh_broker::coded(
|
||||
RpcErrorWire {
|
||||
code: error_code::REFUSED,
|
||||
message,
|
||||
data: None,
|
||||
},
|
||||
dsh_broker::TICKET_REFUSED,
|
||||
)
|
||||
};
|
||||
let cancelled = |message: &str| {
|
||||
dsh_broker::coded(
|
||||
RpcErrorWire {
|
||||
code: error_code::CANCELLED,
|
||||
message: message.to_string(),
|
||||
data: None,
|
||||
},
|
||||
dsh_broker::CANCELLED,
|
||||
)
|
||||
};
|
||||
let service = DshService::parse(¶ms.service)
|
||||
.ok_or_else(|| BrokerRefusal::UnknownService(params.service.clone()).wire())?;
|
||||
let invocation = self
|
||||
.redeem_invocation(
|
||||
shared,
|
||||
tier,
|
||||
host_generation,
|
||||
¶ms.owner,
|
||||
¶ms.ticket,
|
||||
dsh_broker::ticket_method(service),
|
||||
&cx,
|
||||
)
|
||||
.map_err(|error| dsh_broker::coded(error, dsh_broker::TICKET_REFUSED))?;
|
||||
let (session_id, tool) = invocation
|
||||
.broker
|
||||
.as_ref()
|
||||
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
|
||||
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
|
||||
if !dsh_broker::granted(service) {
|
||||
return Err(BrokerRefusal::NotAvailable(service).wire());
|
||||
}
|
||||
let arm = dsh_broker::arm_in(table, service)
|
||||
.ok_or_else(|| BrokerRefusal::NotAvailable(service).wire())?;
|
||||
let withdraw = invocation.cancel.child_token();
|
||||
let _withdraw_guard = withdraw.clone().drop_guard();
|
||||
let call = BrokerCall {
|
||||
owner: params.owner.clone(),
|
||||
session_id,
|
||||
service,
|
||||
method: params.method,
|
||||
input: params.input,
|
||||
context: BrokerContext {
|
||||
invocation: Arc::clone(&invocation),
|
||||
tool,
|
||||
service,
|
||||
cancel: withdraw.clone(),
|
||||
},
|
||||
};
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cx.cancel.cancelled() => return Err(cancelled("dsh/broker was cancelled by its host")),
|
||||
() = withdraw.cancelled() => return Err(cancelled("dsh/broker invocation ended")),
|
||||
outcome = arm(call) => outcome,
|
||||
};
|
||||
if shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.tier_of(¶ms.owner)
|
||||
!= Some(tier)
|
||||
{
|
||||
return Err(refused("dsh/broker owner is no longer current".into()));
|
||||
}
|
||||
shared
|
||||
.check_selection(
|
||||
invocation
|
||||
.plugins
|
||||
.as_ref()
|
||||
.and_then(|plugins| plugins.caller_selection()),
|
||||
invocation.plugins.as_deref(),
|
||||
¶ms.owner.plugin_id,
|
||||
&invocation.content_hash,
|
||||
invocation.scope.as_ref(),
|
||||
)
|
||||
.map_err(refused)?;
|
||||
outcome.map_err(BrokerRefusal::wire)
|
||||
}
|
||||
|
||||
/// Redeem `params` presented by `tier`'s host process `host_generation`
|
||||
/// and run the call. An invalid presentation is a refusal, and a burst of
|
||||
/// them a violation `cx` reports.
|
||||
@@ -1191,6 +1450,7 @@ pub struct InvocationGuard {
|
||||
checkpoint_ticket: Option<Ticket>,
|
||||
question_ticket: Option<Ticket>,
|
||||
subagent_ticket: Option<Ticket>,
|
||||
broker_tickets: Vec<(DshService, Ticket)>,
|
||||
invocation: Arc<Invocation>,
|
||||
}
|
||||
|
||||
@@ -1212,6 +1472,18 @@ impl InvocationGuard {
|
||||
self.subagent_ticket.as_ref().map(Ticket::expose)
|
||||
}
|
||||
|
||||
pub fn broker_tickets(&self) -> Option<Vec<DshBrokerTicketWire>> {
|
||||
(!self.broker_tickets.is_empty()).then(|| {
|
||||
self.broker_tickets
|
||||
.iter()
|
||||
.map(|(service, ticket)| DshBrokerTicketWire {
|
||||
service: service.name().to_string(),
|
||||
ticket: ticket.expose().to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn caller_tools(&self) -> Option<Vec<String>> {
|
||||
self.subagent_ticket
|
||||
.as_ref()
|
||||
|
||||
@@ -625,6 +625,7 @@ struct Fixture {
|
||||
other: OwnerRef,
|
||||
context: ToolContext,
|
||||
gate: NestedCallGate,
|
||||
server: Arc<Server>,
|
||||
}
|
||||
|
||||
fn fixture(uses_server: bool) -> Fixture {
|
||||
@@ -651,13 +652,15 @@ fn fixture(uses_server: bool) -> Fixture {
|
||||
let (tx_event, mut rx_event) = mpsc::channel(8);
|
||||
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
|
||||
let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
|
||||
let server = Arc::new(Server::default());
|
||||
if uses_server {
|
||||
serve(requests, Arc::new(Server::default()));
|
||||
serve(requests, Arc::clone(&server));
|
||||
}
|
||||
Fixture {
|
||||
manager,
|
||||
owner,
|
||||
other,
|
||||
server,
|
||||
context: ToolContext::new(&workspace),
|
||||
gate: gate.for_extension(
|
||||
ExtensionCaller {
|
||||
@@ -1692,3 +1695,876 @@ fn approval_keys_are_scoped_to_the_extension_so_grants_never_cross_origins() {
|
||||
"a grant given to the extension never covers the model's call"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum EchoMethod {
|
||||
#[serde(rename = "echo")]
|
||||
Echo { text: String },
|
||||
}
|
||||
|
||||
impl super::dsh_broker::BrokerMethods for EchoMethod {
|
||||
const NAMES: &'static [&'static str] = &["echo"];
|
||||
}
|
||||
|
||||
fn echo_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let EchoMethod::Echo { text } = super::dsh_broker::decode::<EchoMethod>(&call)?;
|
||||
Ok(json!({
|
||||
"session": call.session_id,
|
||||
"plugin": call.owner.plugin_id,
|
||||
"service": call.service.name(),
|
||||
"text": text,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn in_session(&mut self, session: &str) {
|
||||
self.context = self
|
||||
.context
|
||||
.clone()
|
||||
.with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
|
||||
session.into(),
|
||||
"fixture-model".into(),
|
||||
self.context.workspace.clone(),
|
||||
None,
|
||||
Arc::new(Vec::new()),
|
||||
))
|
||||
.with_origin_turn_id("broker-turn");
|
||||
}
|
||||
|
||||
async fn broker(
|
||||
&self,
|
||||
table: &[(super::protocol::DshService, super::dsh_broker::Arm)],
|
||||
owner: &OwnerRef,
|
||||
ticket: &str,
|
||||
service: &str,
|
||||
method: &str,
|
||||
input: Value,
|
||||
) -> Result<Value, RpcErrorWire> {
|
||||
let (cx, _violations, _cancel) = HostRequestContext::for_test(40);
|
||||
self.broker_in(cx, table, owner, ticket, service, method, input)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn broker_in(
|
||||
&self,
|
||||
cx: HostRequestContext,
|
||||
table: &[(super::protocol::DshService, super::dsh_broker::Arm)],
|
||||
owner: &OwnerRef,
|
||||
ticket: &str,
|
||||
service: &str,
|
||||
method: &str,
|
||||
input: Value,
|
||||
) -> Result<Value, RpcErrorWire> {
|
||||
self.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.serve_broker_in(
|
||||
table,
|
||||
&self.manager.shared,
|
||||
HostTier::Plugin,
|
||||
7,
|
||||
super::protocol::DshBrokerParams {
|
||||
owner: owner.clone(),
|
||||
ticket: ticket.to_string(),
|
||||
service: service.to_string(),
|
||||
method: method.to_string(),
|
||||
input,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn dsh_refusal(result: &Result<Value, RpcErrorWire>) -> (i64, String) {
|
||||
let error = result.as_ref().expect_err("a refusal");
|
||||
let code = error
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|data| data["dsh_refusal"].as_str())
|
||||
.unwrap_or_else(|| panic!("untyped refusal: {error:?}"))
|
||||
.to_string();
|
||||
(error.code, code)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is_typed() {
|
||||
use super::dsh_broker::TABLE;
|
||||
use super::protocol::DshService;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let _grants = super::dsh_broker::TestGrants::only(&DshService::ALL);
|
||||
let mut f = fixture(true);
|
||||
let outside = f.begin(7, &f.owner);
|
||||
assert!(
|
||||
outside.broker_tickets().is_none(),
|
||||
"no session, no broker ticket"
|
||||
);
|
||||
drop(outside);
|
||||
|
||||
f.in_session("broker-session-a");
|
||||
let guard_a = f.begin(7, &f.owner);
|
||||
let tickets_a: BTreeMap<String, String> = guard_a
|
||||
.broker_tickets()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|wire| (wire.service, wire.ticket))
|
||||
.collect();
|
||||
assert_eq!(tickets_a.len(), DshService::ALL.len());
|
||||
let echo: &[(DshService, super::dsh_broker::Arm)] =
|
||||
&[(DshService::Workspace, echo_arm as super::dsh_broker::Arm)];
|
||||
|
||||
for service in DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|s| *s != DshService::Network)
|
||||
{
|
||||
let ticket = &tickets_a[service.name()];
|
||||
let result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
ticket,
|
||||
service.name(),
|
||||
"anything",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
"{}",
|
||||
service.name()
|
||||
);
|
||||
}
|
||||
|
||||
let workspace = tickets_a["workspace"].clone();
|
||||
let result = f
|
||||
.broker(TABLE, &f.owner, &workspace, "vault", "read", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::INVALID_PARAMS, "unknown_service".into())
|
||||
);
|
||||
|
||||
let result = f
|
||||
.broker(echo, &f.owner, &workspace, "workspace", "write", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into())
|
||||
);
|
||||
let result = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&workspace,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": 1}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
let result = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&workspace,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "hi"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({"session": "broker-session-a", "plugin": "a", "service": "workspace", "text": "hi"})
|
||||
);
|
||||
|
||||
let wrong_service = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&tickets_a["llm"],
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&wrong_service),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
let foreign = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.other,
|
||||
&workspace,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&foreign),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
let core_ticket = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
guard_a.ticket(),
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&core_ticket),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
for message in [&wrong_service, &foreign, &core_ticket]
|
||||
.map(|result| result.as_ref().unwrap_err().message.clone())
|
||||
{
|
||||
assert!(!message.contains("cwt."), "{message}");
|
||||
}
|
||||
|
||||
f.in_session("broker-session-b");
|
||||
let guard_b = f.begin(7, &f.owner);
|
||||
let workspace_b = guard_b
|
||||
.broker_tickets()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|wire| wire.service == "workspace")
|
||||
.unwrap()
|
||||
.ticket;
|
||||
let in_b = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&workspace_b,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "b"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(in_b["session"], "broker-session-b");
|
||||
let in_a = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&workspace,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "a"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(in_a["session"], "broker-session-a");
|
||||
|
||||
drop(guard_a);
|
||||
let stale = f
|
||||
.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&workspace,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&stale),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
assert!(
|
||||
f.broker(
|
||||
echo,
|
||||
&f.owner,
|
||||
&workspace_b,
|
||||
"workspace",
|
||||
"echo",
|
||||
json!({"text": "b"})
|
||||
)
|
||||
.await
|
||||
.is_ok(),
|
||||
"session b's invocation is untouched"
|
||||
);
|
||||
drop(guard_b);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
|
||||
for service in DshService::ALL {
|
||||
assert_eq!(
|
||||
TABLE.iter().filter(|(row, _)| *row == service).count(),
|
||||
1,
|
||||
"{} has exactly one broker arm",
|
||||
service.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn broker_ticket(guard: &super::core_call::InvocationGuard, service: &str) -> Option<String> {
|
||||
guard
|
||||
.broker_tickets()?
|
||||
.into_iter()
|
||||
.find(|wire| wire.service == service)
|
||||
.map(|wire| wire.ticket)
|
||||
}
|
||||
|
||||
fn every_service(
|
||||
arm: super::dsh_broker::Arm,
|
||||
) -> Vec<(super::protocol::DshService, super::dsh_broker::Arm)> {
|
||||
super::protocol::DshService::ALL
|
||||
.into_iter()
|
||||
.map(|service| (service, arm))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_is_refused() {
|
||||
use super::dsh_broker::{TABLE, TestGrants};
|
||||
use super::protocol::DshService;
|
||||
|
||||
let mut f = fixture(true);
|
||||
f.in_session("grant-session");
|
||||
let echo = every_service(echo_arm);
|
||||
|
||||
assert!(
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !super::dsh_broker::granted(service))
|
||||
);
|
||||
let none = f.begin(7, &f.owner);
|
||||
assert!(
|
||||
none.broker_tickets().is_none(),
|
||||
"nothing granted, no broker ticket"
|
||||
);
|
||||
drop(none);
|
||||
|
||||
for service in DshService::ALL {
|
||||
let grants = TestGrants::only(&[service]);
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let tickets = guard.broker_tickets().unwrap();
|
||||
assert_eq!(
|
||||
tickets
|
||||
.iter()
|
||||
.map(|wire| wire.service.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![service.name()],
|
||||
"only the granted service is ticketed"
|
||||
);
|
||||
let ticket = &tickets[0].ticket;
|
||||
let served = f
|
||||
.broker(
|
||||
&echo,
|
||||
&f.owner,
|
||||
ticket,
|
||||
service.name(),
|
||||
"echo",
|
||||
json!({"text": "ok"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(served["service"], service.name());
|
||||
for other in DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|other| *other != service)
|
||||
{
|
||||
let result = f
|
||||
.broker(
|
||||
&echo,
|
||||
&f.owner,
|
||||
ticket,
|
||||
other.name(),
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "ticket_refused".into()),
|
||||
"{} with a {} ticket",
|
||||
other.name(),
|
||||
service.name()
|
||||
);
|
||||
}
|
||||
if service != DshService::Network {
|
||||
let result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.owner,
|
||||
ticket,
|
||||
service.name(),
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
"the landed {} stub still refuses",
|
||||
service.name()
|
||||
);
|
||||
}
|
||||
|
||||
drop(grants);
|
||||
let withdrawn = f
|
||||
.broker(
|
||||
&echo,
|
||||
&f.owner,
|
||||
ticket,
|
||||
service.name(),
|
||||
"echo",
|
||||
json!({"text": "x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&withdrawn),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
"a grant withdrawn after minting refuses {} before the arm runs",
|
||||
service.name()
|
||||
);
|
||||
assert_eq!(
|
||||
withdrawn.unwrap_err().message,
|
||||
format!("the {} broker is not available yet", service.name()),
|
||||
"the refusal names no grant"
|
||||
);
|
||||
drop(guard);
|
||||
}
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
fn context_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let tool = call.context.tool();
|
||||
Ok::<_, super::dsh_broker::BrokerRefusal>(json!({
|
||||
"session": call.session_id,
|
||||
"plugin": call.owner.plugin_id,
|
||||
"workspace": tool.workspace.display().to_string(),
|
||||
"tool_session": tool.session_objects.as_ref().map(|session| session.session_id.clone()),
|
||||
"approval_mode": format!("{:?}", tool.approval_mode),
|
||||
"trust_mode": tool.trust_mode,
|
||||
"disallowed": tool.disallowed_tools,
|
||||
"nested_gate_visible": tool.nested_call_gate.is_some(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broker_arm_acts_with_its_invocations_context_whatever_the_plugin_sends() {
|
||||
use super::dsh_broker::TestGrants;
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace]);
|
||||
let table = every_service(context_arm);
|
||||
let mut f = fixture(true);
|
||||
f.in_session("context-a");
|
||||
f.context.approval_mode = codewhale_execpolicy::ApprovalMode::Never;
|
||||
f.context.trust_mode = false;
|
||||
f.context.disallowed_tools = vec!["exec_shell".into()];
|
||||
let workspace_a = f.context.workspace.display().to_string();
|
||||
let guard_a = f.begin(7, &f.owner);
|
||||
let ticket_a = broker_ticket(&guard_a, "workspace").unwrap();
|
||||
|
||||
let other_dir = tempfile::tempdir().unwrap();
|
||||
let mut other = f.context.clone();
|
||||
other.workspace = other_dir.path().to_path_buf();
|
||||
other.approval_mode = codewhale_execpolicy::ApprovalMode::Bypass;
|
||||
other.trust_mode = true;
|
||||
other.disallowed_tools = Vec::new();
|
||||
let saved = std::mem::replace(&mut f.context, other);
|
||||
f.in_session("context-b");
|
||||
let guard_b = f.begin(7, &f.owner);
|
||||
let ticket_b = broker_ticket(&guard_b, "workspace").unwrap();
|
||||
f.context = saved;
|
||||
|
||||
let forged = json!({
|
||||
"workspace": "/etc",
|
||||
"session_id": "context-b",
|
||||
"approval_mode": "Bypass",
|
||||
"trust_mode": true,
|
||||
"owner": {"plugin_id": "b"},
|
||||
"ticket": ticket_b,
|
||||
});
|
||||
let seen_a = f
|
||||
.broker(
|
||||
&table,
|
||||
&f.owner,
|
||||
&ticket_a,
|
||||
"workspace",
|
||||
"anything",
|
||||
forged.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen_a,
|
||||
json!({
|
||||
"session": "context-a",
|
||||
"plugin": "a",
|
||||
"workspace": workspace_a,
|
||||
"tool_session": "context-a",
|
||||
"approval_mode": "Never",
|
||||
"trust_mode": false,
|
||||
"disallowed": ["exec_shell"],
|
||||
"nested_gate_visible": false,
|
||||
})
|
||||
);
|
||||
let seen_b = f
|
||||
.broker(&table, &f.owner, &ticket_b, "workspace", "anything", forged)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(seen_b["session"], "context-b");
|
||||
assert_eq!(seen_b["workspace"], other_dir.path().display().to_string());
|
||||
assert_eq!(seen_b["approval_mode"], "Bypass");
|
||||
assert_eq!(seen_b["trust_mode"], true);
|
||||
|
||||
let foreign = f
|
||||
.broker(
|
||||
&table,
|
||||
&f.other,
|
||||
&ticket_a,
|
||||
"workspace",
|
||||
"anything",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&foreign),
|
||||
(error_code::REFUSED, "ticket_refused".into()),
|
||||
"another plugin cannot borrow this invocation's context"
|
||||
);
|
||||
drop(guard_a);
|
||||
drop(guard_b);
|
||||
}
|
||||
|
||||
static CANCEL_ARM_STARTED: AtomicUsize = AtomicUsize::new(0);
|
||||
static CANCEL_ARM_SAW: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn cancel_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
|
||||
let cancel = call.context.cancel().clone();
|
||||
tokio::spawn(async move {
|
||||
cancel.cancelled().await;
|
||||
CANCEL_ARM_SAW.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
Box::pin(async move {
|
||||
assert!(!call.context.cancel().is_cancelled());
|
||||
CANCEL_ARM_STARTED.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::pending::<Result<Value, super::dsh_broker::BrokerRefusal>>().await
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broker_arm_sees_its_call_cancelled_by_the_host_the_invocation_and_the_owner() {
|
||||
use super::dsh_broker::TestGrants;
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Terminal]);
|
||||
let table = every_service(cancel_arm);
|
||||
let mut f = fixture(true);
|
||||
f.in_session("cancel-session");
|
||||
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard, "terminal").unwrap();
|
||||
let (cx, _violations, host_cancel) = HostRequestContext::for_test(41);
|
||||
let (result, ()) = tokio::join!(
|
||||
f.broker_in(cx, &table, &f.owner, &ticket, "terminal", "run", json!({})),
|
||||
async {
|
||||
until("the arm to start", || {
|
||||
CANCEL_ARM_STARTED.load(Ordering::SeqCst) == 1
|
||||
})
|
||||
.await;
|
||||
host_cancel.cancel();
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::CANCELLED, "cancelled".into())
|
||||
);
|
||||
until("the arm to see the host's cancel", || {
|
||||
CANCEL_ARM_SAW.load(Ordering::SeqCst) == 1
|
||||
})
|
||||
.await;
|
||||
|
||||
let (cx, _violations, _host_cancel) = HostRequestContext::for_test(42);
|
||||
let (result, ()) = tokio::join!(
|
||||
f.broker_in(cx, &table, &f.owner, &ticket, "terminal", "run", json!({})),
|
||||
async {
|
||||
until("the arm to start again", || {
|
||||
CANCEL_ARM_STARTED.load(Ordering::SeqCst) == 2
|
||||
})
|
||||
.await;
|
||||
drop(guard);
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::CANCELLED, "cancelled".into())
|
||||
);
|
||||
until("the arm to see the invocation end", || {
|
||||
CANCEL_ARM_SAW.load(Ordering::SeqCst) == 2
|
||||
})
|
||||
.await;
|
||||
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard, "terminal").unwrap();
|
||||
let (cx, _violations, _host_cancel) = HostRequestContext::for_test(43);
|
||||
let (result, ()) = tokio::join!(
|
||||
f.broker_in(cx, &table, &f.owner, &ticket, "terminal", "run", json!({})),
|
||||
async {
|
||||
until("the arm to start a third time", || {
|
||||
CANCEL_ARM_STARTED.load(Ordering::SeqCst) == 3
|
||||
})
|
||||
.await;
|
||||
f.manager.shared.core_calls.revoke_owner("a");
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::CANCELLED, "cancelled".into())
|
||||
);
|
||||
until("the arm to see the owner revoked", || {
|
||||
CANCEL_ARM_SAW.load(Ordering::SeqCst) == 3
|
||||
})
|
||||
.await;
|
||||
drop(guard);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum NestedMethod {
|
||||
#[serde(rename = "core")]
|
||||
Core { name: String, input: Value },
|
||||
}
|
||||
|
||||
impl super::dsh_broker::BrokerMethods for NestedMethod {
|
||||
const NAMES: &'static [&'static str] = &["core"];
|
||||
}
|
||||
|
||||
fn nested_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let NestedMethod::Core { name, input } = super::dsh_broker::decode::<NestedMethod>(&call)?;
|
||||
let response = call.context.core_call(&name, input).await?;
|
||||
Ok(json!({ "ok": response.ok, "result": response.result }))
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_brokers_nested_core_call_goes_through_the_same_gate_as_a_direct_call() {
|
||||
use super::dsh_broker::TestGrants;
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace]);
|
||||
let table = every_service(nested_arm);
|
||||
let mut f = fixture(true);
|
||||
f.in_session("nested-session");
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let ticket = broker_ticket(&guard, "workspace").unwrap();
|
||||
|
||||
let ran = f
|
||||
.broker(
|
||||
&table,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"core",
|
||||
json!({"name": "read_file", "input": {"path": "x"}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ran["ok"], true, "{ran}");
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the gate was asked"
|
||||
);
|
||||
|
||||
for refused_name in ["remember", "execute_tools", "tool_search", "mcp__any__tool"] {
|
||||
let result = f
|
||||
.broker(
|
||||
&table,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"core",
|
||||
json!({"name": refused_name, "input": {}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "refused".into()),
|
||||
"{refused_name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a refused name never reaches the gate, as with core/call"
|
||||
);
|
||||
|
||||
f.server.hold.store(true, Ordering::SeqCst);
|
||||
let (cx, _violations, host_cancel) = HostRequestContext::for_test(44);
|
||||
let (result, ()) = tokio::join!(
|
||||
f.broker_in(
|
||||
cx,
|
||||
&table,
|
||||
&f.owner,
|
||||
&ticket,
|
||||
"workspace",
|
||||
"core",
|
||||
json!({"name": "read_file", "input": {"path": "x"}}),
|
||||
),
|
||||
async {
|
||||
until("the card to open", || {
|
||||
f.server.asked.load(Ordering::SeqCst) == 2
|
||||
})
|
||||
.await;
|
||||
host_cancel.cancel();
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::CANCELLED, "cancelled".into())
|
||||
);
|
||||
until("the open card to be withdrawn", || {
|
||||
f.server.withdrawn.load(Ordering::SeqCst) == 1
|
||||
})
|
||||
.await;
|
||||
f.server.hold.store(false, Ordering::SeqCst);
|
||||
|
||||
let receipts = guard.receipts().expect("nested calls are receipted");
|
||||
assert!(receipts["total"].as_u64().unwrap() >= 2, "{receipts}");
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_invocations_brokers_share_one_cap_on_nested_core_calls() {
|
||||
use super::dsh_broker::TestGrants;
|
||||
use super::protocol::DshService;
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Terminal]);
|
||||
let table = every_service(nested_arm);
|
||||
let mut f = fixture(true);
|
||||
f.in_session("cap-session");
|
||||
let guard = f.begin(7, &f.owner);
|
||||
let workspace = broker_ticket(&guard, "workspace").unwrap();
|
||||
let terminal = broker_ticket(&guard, "terminal").unwrap();
|
||||
let call = json!({"name": "read_file", "input": {"path": "x"}});
|
||||
for n in 0..MAX_CALLS_PER_INVOCATION {
|
||||
let (ticket, service) = if n % 2 == 0 {
|
||||
(&workspace, "workspace")
|
||||
} else {
|
||||
(&terminal, "terminal")
|
||||
};
|
||||
let ran = f
|
||||
.broker(&table, &f.owner, ticket, service, "core", call.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ran["ok"], true);
|
||||
}
|
||||
let capped = f
|
||||
.broker(
|
||||
&table,
|
||||
&f.owner,
|
||||
&workspace,
|
||||
"workspace",
|
||||
"core",
|
||||
call.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&capped),
|
||||
(error_code::REFUSED, "refused".into())
|
||||
);
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
MAX_CALLS_PER_INVOCATION as usize
|
||||
);
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_network_arm_refuses_foreign_tickets_unknown_methods_and_revoked_owners() {
|
||||
use super::dsh_broker::TABLE;
|
||||
use super::protocol::DshService;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let _grants =
|
||||
super::dsh_broker::TestGrants::only(&[DshService::Workspace, DshService::Network]);
|
||||
let mut f = fixture(true);
|
||||
f.in_session("net-session-a");
|
||||
let guard_a = f.begin(7, &f.owner);
|
||||
let tickets: BTreeMap<String, String> = guard_a
|
||||
.broker_tickets()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|wire| (wire.service, wire.ticket))
|
||||
.collect();
|
||||
let network = tickets["network"].clone();
|
||||
let fetch = json!({"url": "https://example.com/"});
|
||||
|
||||
let unknown = f
|
||||
.broker(TABLE, &f.owner, &network, "network", "connect", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&unknown),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into())
|
||||
);
|
||||
let withheld = f
|
||||
.broker(TABLE, &f.owner, &network, "network", "fetch", fetch.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&withheld),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into())
|
||||
);
|
||||
for (owner, ticket) in [
|
||||
(&f.other, network.as_str()),
|
||||
(&f.owner, tickets["workspace"].as_str()),
|
||||
(&f.owner, guard_a.ticket()),
|
||||
] {
|
||||
let result = f
|
||||
.broker(TABLE, owner, ticket, "network", "fetch", fetch.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
}
|
||||
|
||||
f.in_session("net-session-b");
|
||||
let guard_b = f.begin(7, &f.owner);
|
||||
let network_b = guard_b
|
||||
.broker_tickets()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|wire| wire.service == "network")
|
||||
.unwrap()
|
||||
.ticket;
|
||||
drop(guard_a);
|
||||
let other_session = f
|
||||
.broker(TABLE, &f.owner, &network, "network", "fetch", fetch.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&other_session),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
|
||||
f.manager.shared.core_calls.revoke_owner("a");
|
||||
let revoked = f
|
||||
.broker(TABLE, &f.owner, &network_b, "network", "fetch", fetch)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&revoked),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
drop(guard_b);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use codewhale_config::OwnerRef;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::protocol::{DshService, RpcErrorWire, error_code};
|
||||
use super::{
|
||||
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
|
||||
};
|
||||
use crate::tools::codemode::{NestedDecision, NestedFailure};
|
||||
|
||||
pub use super::core_call::BrokerContext;
|
||||
|
||||
pub struct BrokerCall {
|
||||
pub owner: OwnerRef,
|
||||
pub session_id: String,
|
||||
pub service: DshService,
|
||||
pub method: String,
|
||||
pub input: Value,
|
||||
pub context: BrokerContext,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BrokerRefusal {
|
||||
UnknownService(String),
|
||||
UnknownMethod {
|
||||
service: DshService,
|
||||
method: String,
|
||||
},
|
||||
InvalidInput {
|
||||
service: DshService,
|
||||
message: String,
|
||||
},
|
||||
NotAvailable(DshService),
|
||||
Denied {
|
||||
service: DshService,
|
||||
message: String,
|
||||
},
|
||||
Refused {
|
||||
service: DshService,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BrokerRefusal {
|
||||
#[must_use]
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UnknownService(_) => "unknown_service",
|
||||
Self::UnknownMethod { .. } => "unknown_method",
|
||||
Self::InvalidInput { .. } => "invalid_input",
|
||||
Self::NotAvailable(_) => "not_available",
|
||||
Self::Denied { .. } => "denied",
|
||||
Self::Refused { .. } => "refused",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn wire(self) -> RpcErrorWire {
|
||||
let code = self.code();
|
||||
let (rpc, message, service) = match self {
|
||||
Self::UnknownService(name) => (
|
||||
error_code::INVALID_PARAMS,
|
||||
"dsh/broker names a service the core does not broker".to_string(),
|
||||
Value::String(name.chars().take(64).collect()),
|
||||
),
|
||||
Self::UnknownMethod { service, method } => (
|
||||
error_code::INVALID_PARAMS,
|
||||
format!(
|
||||
"the {} broker has no method `{}`",
|
||||
service.name(),
|
||||
method.chars().take(64).collect::<String>()
|
||||
),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::InvalidInput { service, message } => (
|
||||
error_code::INVALID_PARAMS,
|
||||
format!("invalid {} broker input: {message}", service.name()),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::NotAvailable(service) => (
|
||||
error_code::NOT_AVAILABLE,
|
||||
format!("the {} broker is not available yet", service.name()),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::Denied { service, message } => (
|
||||
error_code::DENIED,
|
||||
message,
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::Refused { service, message } => (
|
||||
error_code::REFUSED,
|
||||
message,
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
};
|
||||
RpcErrorWire {
|
||||
code: rpc,
|
||||
message,
|
||||
data: Some(json!({ "dsh_refusal": code, "service": service })),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn nested(service: DshService, failure: NestedFailure) -> Self {
|
||||
match failure {
|
||||
NestedFailure::Rejected {
|
||||
decision: NestedDecision::Denied,
|
||||
message,
|
||||
} => Self::Denied { service, message },
|
||||
NestedFailure::Rejected { message, .. } | NestedFailure::Unavailable(message) => {
|
||||
Self::Refused { service, message }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const TICKET_REFUSED: &str = "ticket_refused";
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
#[must_use]
|
||||
pub fn coded(mut error: RpcErrorWire, code: &str) -> RpcErrorWire {
|
||||
error.data = Some(json!({ "dsh_refusal": code }));
|
||||
error
|
||||
}
|
||||
|
||||
pub type BrokerFuture = Pin<Box<dyn Future<Output = Result<Value, BrokerRefusal>> + Send>>;
|
||||
pub type Arm = fn(BrokerCall) -> BrokerFuture;
|
||||
|
||||
pub const TABLE: &[(DshService, Arm)] = &[
|
||||
(DshService::Workspace, dsh_workspace::serve as Arm),
|
||||
(DshService::Llm, dsh_llm::serve as Arm),
|
||||
(DshService::Network, dsh_network::serve as Arm),
|
||||
(DshService::Settings, dsh_settings::serve as Arm),
|
||||
(DshService::Catalog, dsh_catalog::serve as Arm),
|
||||
(DshService::Terminal, dsh_terminal::serve as Arm),
|
||||
(DshService::Remote, dsh_remote::serve as Arm),
|
||||
];
|
||||
|
||||
const GRANTED: &[DshService] = &[];
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct TestGrants(Option<Vec<DshService>>);
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestGrants {
|
||||
pub fn only(services: &[DshService]) -> Self {
|
||||
Self(TEST_GRANTS.with(|grants| grants.replace(Some(services.to_vec()))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestGrants {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
TEST_GRANTS.with(|grants| *grants.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(service: DshService) -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(granted) = TEST_GRANTS.with(|grants| grants.borrow().clone()) {
|
||||
return granted.contains(&service);
|
||||
}
|
||||
GRANTED.contains(&service)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn arm_in(table: &[(DshService, Arm)], service: DshService) -> Option<Arm> {
|
||||
table
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == service)
|
||||
.map(|(_, arm)| *arm)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ticket_method(service: DshService) -> &'static str {
|
||||
match service {
|
||||
DshService::Workspace => "dsh/broker:workspace",
|
||||
DshService::Llm => "dsh/broker:llm",
|
||||
DshService::Network => "dsh/broker:network",
|
||||
DshService::Settings => "dsh/broker:settings",
|
||||
DshService::Catalog => "dsh/broker:catalog",
|
||||
DshService::Terminal => "dsh/broker:terminal",
|
||||
DshService::Remote => "dsh/broker:remote",
|
||||
}
|
||||
}
|
||||
|
||||
pub trait BrokerMethods: DeserializeOwned {
|
||||
const NAMES: &'static [&'static str];
|
||||
}
|
||||
|
||||
pub fn decode<M: BrokerMethods>(call: &BrokerCall) -> Result<M, BrokerRefusal> {
|
||||
if !M::NAMES.contains(&call.method.as_str()) {
|
||||
return Err(BrokerRefusal::UnknownMethod {
|
||||
service: call.service,
|
||||
method: call.method.clone(),
|
||||
});
|
||||
}
|
||||
serde_json::from_value(json!({ "method": call.method, "input": call.input })).map_err(|error| {
|
||||
BrokerRefusal::InvalidInput {
|
||||
service: call.service,
|
||||
message: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn not_available(service: DshService) -> BrokerFuture {
|
||||
Box::pin(std::future::ready(Err(BrokerRefusal::NotAvailable(
|
||||
service,
|
||||
))))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Catalog)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Llm)
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use codewhale_secrets::Secrets;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::spec::ToolContext;
|
||||
use crate::tools::web::guard::validate_fetch_target;
|
||||
|
||||
pub const CREDENTIAL_HANDLE_PREFIX: &str = "codewhale-credential:";
|
||||
const MAX_REF_LEN: usize = 128;
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
const MAX_HEADERS: usize = 64;
|
||||
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||
const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const AUDIT_LABEL: &str = "dsh_network";
|
||||
const FORBIDDEN_HEADERS: &[&str] = &[
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"keep-alive",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
];
|
||||
const METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum NetworkMethod {
|
||||
#[serde(rename = "fetch")]
|
||||
Fetch(FetchInput),
|
||||
#[serde(rename = "credentials.describe")]
|
||||
Describe(CredentialRef),
|
||||
#[serde(rename = "credentials.resolve")]
|
||||
Resolve(CredentialRef),
|
||||
#[serde(rename = "credentials.set")]
|
||||
Set(CredentialWrite),
|
||||
}
|
||||
|
||||
impl BrokerMethods for NetworkMethod {
|
||||
const NAMES: &'static [&'static str] = &[
|
||||
"fetch",
|
||||
"credentials.describe",
|
||||
"credentials.resolve",
|
||||
"credentials.set",
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FetchInput {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
method: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: Vec<(String, String)>,
|
||||
#[serde(default)]
|
||||
body_base64: Option<String>,
|
||||
#[serde(default)]
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CredentialRef {
|
||||
#[serde(rename = "ref")]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CredentialWrite {
|
||||
#[serde(rename = "ref")]
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
pub(super) struct NetworkAuthority {
|
||||
pub hosts: BTreeSet<String>,
|
||||
pub tool: Option<ToolContext>,
|
||||
pub vault: Option<Secrets>,
|
||||
}
|
||||
|
||||
impl NetworkAuthority {
|
||||
fn withheld() -> Self {
|
||||
Self {
|
||||
hosts: BTreeSet::new(),
|
||||
tool: None,
|
||||
vault: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
serve_with(call, NetworkAuthority::withheld())
|
||||
}
|
||||
|
||||
pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let method = decode::<NetworkMethod>(&call)?;
|
||||
let plugin = call.owner.plugin_id.clone();
|
||||
match method {
|
||||
NetworkMethod::Fetch(input) => {
|
||||
let Some(tool) = authority.tool.as_ref() else {
|
||||
return Err(BrokerRefusal::NotAvailable(DshService::Network));
|
||||
};
|
||||
fetch(
|
||||
&plugin,
|
||||
&authority.hosts,
|
||||
tool,
|
||||
authority.vault.as_ref(),
|
||||
input,
|
||||
)
|
||||
.await
|
||||
}
|
||||
NetworkMethod::Describe(CredentialRef { name }) => {
|
||||
let vault = vault(&authority)?;
|
||||
let name = checked_ref(&name)?;
|
||||
let set = read_secret(vault, &plugin, name)?.is_some();
|
||||
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential described");
|
||||
Ok(json!({ "ref": name, "set": set, "handle": handle(name) }))
|
||||
}
|
||||
NetworkMethod::Resolve(CredentialRef { name }) => {
|
||||
let vault = vault(&authority)?;
|
||||
let name = checked_ref(&name)?;
|
||||
let set = read_secret(vault, &plugin, name)?.is_some();
|
||||
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential resolved to its handle");
|
||||
Ok(json!({ "ref": name, "value": set.then(|| handle(name)) }))
|
||||
}
|
||||
NetworkMethod::Set(CredentialWrite { name, value }) => {
|
||||
let vault = vault(&authority)?;
|
||||
let name = checked_ref(&name)?;
|
||||
if value.is_empty() || value.contains(CREDENTIAL_HANDLE_PREFIX) {
|
||||
return Err(invalid(
|
||||
"a credential value must be a non-empty secret, not a handle",
|
||||
));
|
||||
}
|
||||
vault
|
||||
.set(&vault_slot(&plugin, name), &value)
|
||||
.map_err(|_| invalid("the credential vault refused the write"))?;
|
||||
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, "credential stored");
|
||||
Ok(json!({ "ref": name, "set": true, "handle": handle(name) }))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> BrokerRefusal {
|
||||
BrokerRefusal::InvalidInput {
|
||||
service: DshService::Network,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn vault(authority: &NetworkAuthority) -> Result<&Secrets, BrokerRefusal> {
|
||||
authority
|
||||
.vault
|
||||
.as_ref()
|
||||
.ok_or(BrokerRefusal::NotAvailable(DshService::Network))
|
||||
}
|
||||
|
||||
fn checked_ref(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
let ok = !name.is_empty()
|
||||
&& name.len() <= MAX_REF_LEN
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
|
||||
if ok {
|
||||
Ok(name)
|
||||
} else {
|
||||
Err(invalid(format!(
|
||||
"a credential ref is 1 to {MAX_REF_LEN} letters, digits, `_`, `-` or `.`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(name: &str) -> String {
|
||||
format!("{CREDENTIAL_HANDLE_PREFIX}{name}")
|
||||
}
|
||||
|
||||
fn vault_slot(plugin: &str, name: &str) -> String {
|
||||
format!("dsh-plugin:{plugin}:{name}")
|
||||
}
|
||||
|
||||
fn read_secret(vault: &Secrets, plugin: &str, name: &str) -> Result<Option<String>, BrokerRefusal> {
|
||||
vault
|
||||
.get(&vault_slot(plugin, name))
|
||||
.map(|value| value.filter(|value| !value.is_empty()))
|
||||
.map_err(|_| invalid("the credential vault could not be read"))
|
||||
}
|
||||
|
||||
fn normalized_host(host: &str) -> String {
|
||||
host.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn same_origin(a: &reqwest::Url, b: &reqwest::Url) -> bool {
|
||||
a.scheme() == b.scheme()
|
||||
&& a.host_str().map(normalized_host) == b.host_str().map(normalized_host)
|
||||
&& a.port_or_known_default() == b.port_or_known_default()
|
||||
}
|
||||
|
||||
fn egress_host(url: &reqwest::Url, hosts: &BTreeSet<String>) -> Result<String, BrokerRefusal> {
|
||||
if url.scheme() != "http" && url.scheme() != "https" {
|
||||
return Err(invalid(
|
||||
"egress refused: only http and https URLs are brokered",
|
||||
));
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(invalid("egress refused: URLs may not carry userinfo"));
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.map(normalized_host)
|
||||
.ok_or_else(|| invalid("egress refused: the URL has no host"))?;
|
||||
if hosts.iter().any(|allowed| normalized_host(allowed) == host) {
|
||||
Ok(host)
|
||||
} else {
|
||||
Err(invalid(format!(
|
||||
"egress refused: `{}` is not one of this plugin's reviewed network hosts",
|
||||
host.chars().take(253).collect::<String>()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
struct Substituted {
|
||||
headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
|
||||
secrets: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn substitute_headers(
|
||||
plugin: &str,
|
||||
vault: Option<&Secrets>,
|
||||
headers: &[(String, String)],
|
||||
) -> Result<Substituted, BrokerRefusal> {
|
||||
let mut out = Vec::with_capacity(headers.len());
|
||||
let mut secrets: Vec<(String, String)> = Vec::new();
|
||||
for (name, value) in headers {
|
||||
let mut resolved = String::with_capacity(value.len());
|
||||
let mut rest = value.as_str();
|
||||
while let Some(at) = rest.find(CREDENTIAL_HANDLE_PREFIX) {
|
||||
resolved.push_str(&rest[..at]);
|
||||
let tail = &rest[at + CREDENTIAL_HANDLE_PREFIX.len()..];
|
||||
let end = tail
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))
|
||||
.unwrap_or(tail.len());
|
||||
let reference = checked_ref(&tail[..end])?;
|
||||
let vault = vault.ok_or(BrokerRefusal::NotAvailable(DshService::Network))?;
|
||||
let secret = read_secret(vault, plugin, reference)?.ok_or_else(|| {
|
||||
invalid(format!(
|
||||
"credential `{reference}` is not set for this plugin"
|
||||
))
|
||||
})?;
|
||||
resolved.push_str(&secret);
|
||||
if !secrets.iter().any(|(_, known)| *known == secret) {
|
||||
secrets.push((handle(reference), secret));
|
||||
}
|
||||
rest = &tail[end..];
|
||||
}
|
||||
resolved.push_str(rest);
|
||||
let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| invalid("a request header name is not valid"))?;
|
||||
let header_value = reqwest::header::HeaderValue::from_str(&resolved).map_err(|_| {
|
||||
invalid(format!(
|
||||
"request header `{header_name}` has an invalid value"
|
||||
))
|
||||
})?;
|
||||
out.push((header_name, header_value));
|
||||
}
|
||||
Ok(Substituted {
|
||||
headers: out,
|
||||
secrets,
|
||||
})
|
||||
}
|
||||
|
||||
fn scrub(bytes: &[u8], secrets: &[(String, String)]) -> Vec<u8> {
|
||||
let mut out = bytes.to_vec();
|
||||
for (handle, secret) in secrets {
|
||||
let needle = secret.as_bytes();
|
||||
if needle.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut next = Vec::with_capacity(out.len());
|
||||
let mut i = 0;
|
||||
while i < out.len() {
|
||||
if out[i..].starts_with(needle) {
|
||||
next.extend_from_slice(handle.as_bytes());
|
||||
i += needle.len();
|
||||
} else {
|
||||
next.push(out[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn fetch(
|
||||
plugin: &str,
|
||||
hosts: &BTreeSet<String>,
|
||||
tool: &ToolContext,
|
||||
vault: Option<&Secrets>,
|
||||
input: FetchInput,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let mut url = reqwest::Url::parse(&input.url).map_err(|_| invalid("the URL is not valid"))?;
|
||||
if input.url.contains(CREDENTIAL_HANDLE_PREFIX) {
|
||||
return Err(invalid(
|
||||
"credential handles are substituted only in request header values",
|
||||
));
|
||||
}
|
||||
let mut method = input
|
||||
.method
|
||||
.as_deref()
|
||||
.unwrap_or("GET")
|
||||
.to_ascii_uppercase();
|
||||
if !METHODS.contains(&method.as_str()) {
|
||||
return Err(invalid(
|
||||
"the request method is not one the network broker sends",
|
||||
));
|
||||
}
|
||||
if input.headers.len() > MAX_HEADERS
|
||||
|| input
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(name, value)| name.len() + value.len())
|
||||
.sum::<usize>()
|
||||
> MAX_HEADER_BYTES
|
||||
{
|
||||
return Err(invalid("too many or too large request headers"));
|
||||
}
|
||||
if let Some((name, _)) = input.headers.iter().find(|(name, _)| {
|
||||
FORBIDDEN_HEADERS
|
||||
.iter()
|
||||
.any(|forbidden| name.eq_ignore_ascii_case(forbidden))
|
||||
}) {
|
||||
return Err(invalid(format!(
|
||||
"request header `{}` is set by the broker, not the plugin",
|
||||
name.chars().take(64).collect::<String>()
|
||||
)));
|
||||
}
|
||||
let mut body = match input.body_base64.as_deref() {
|
||||
Some(encoded) => {
|
||||
let bytes = STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| invalid("body_base64 is not valid base64"))?;
|
||||
if bytes.len() > MAX_REQUEST_BYTES {
|
||||
return Err(invalid("the request body is too large"));
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let timeout = input
|
||||
.timeout_ms
|
||||
.map_or(DEFAULT_TIMEOUT, Duration::from_millis)
|
||||
.min(MAX_TIMEOUT);
|
||||
let deadline = Instant::now() + timeout;
|
||||
let origin = url.clone();
|
||||
let host = egress_host(&url, hosts)?;
|
||||
let mut redirects = 0usize;
|
||||
let mut substituted: Option<Substituted> = None;
|
||||
let response = loop {
|
||||
egress_host(&url, hosts)?;
|
||||
let pin = validate_fetch_target(&url, tool, AUDIT_LABEL)
|
||||
.await
|
||||
.map_err(|error| invalid(format!("egress refused: {error}")))?;
|
||||
let substitution = match substituted.take() {
|
||||
Some(done) => done,
|
||||
None => substitute_headers(plugin, vault, &input.headers)?,
|
||||
};
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(invalid("the request timed out"));
|
||||
}
|
||||
let mut builder = codewhale_net::guard::guarded_reqwest_client_builder()
|
||||
.timeout(remaining)
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
if let Some((name, ip)) = pin {
|
||||
builder = builder.resolve(&name, std::net::SocketAddr::new(ip, 0));
|
||||
}
|
||||
let client = builder
|
||||
.build()
|
||||
.map_err(|_| invalid("the network broker could not build its client"))?;
|
||||
let verb = reqwest::Method::from_bytes(method.as_bytes())
|
||||
.map_err(|_| invalid("the request method is not valid"))?;
|
||||
let mut request = client.request(verb, url.clone());
|
||||
for (name, value) in &substitution.headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(bytes) = body.clone() {
|
||||
request = request.body(bytes);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
invalid(format!(
|
||||
"the request failed: {}",
|
||||
if error.is_timeout() {
|
||||
"timed out"
|
||||
} else if error.is_connect() {
|
||||
"could not connect"
|
||||
} else {
|
||||
"transport error"
|
||||
}
|
||||
))
|
||||
})?;
|
||||
substituted = Some(substitution);
|
||||
let status = response.status();
|
||||
if !status.is_redirection() {
|
||||
break response;
|
||||
}
|
||||
let Some(location) = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
break response;
|
||||
};
|
||||
if redirects >= MAX_REDIRECTS {
|
||||
return Err(invalid("the request exceeded the five-redirect limit"));
|
||||
}
|
||||
let next = url
|
||||
.join(location)
|
||||
.map_err(|_| invalid("the redirect location is not valid"))?;
|
||||
if !same_origin(&origin, &next) {
|
||||
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, host = %host, "cross-origin redirect refused");
|
||||
return Err(invalid(
|
||||
"egress refused: a redirect may not leave the requested origin",
|
||||
));
|
||||
}
|
||||
if status == reqwest::StatusCode::SEE_OTHER
|
||||
|| (matches!(status.as_u16(), 301 | 302) && method == "POST")
|
||||
{
|
||||
method = "GET".into();
|
||||
body = None;
|
||||
}
|
||||
url = next;
|
||||
redirects += 1;
|
||||
};
|
||||
let secrets = substituted.map(|done| done.secrets).unwrap_or_default();
|
||||
let status = response.status().as_u16();
|
||||
let final_url = response.url().to_string();
|
||||
let headers: Vec<(String, String)> = response
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
let value = String::from_utf8(scrub(value.as_bytes(), &secrets)).ok()?;
|
||||
Some((name.as_str().to_string(), value))
|
||||
})
|
||||
.collect();
|
||||
let mut response = response;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let chunk = tokio::time::timeout(remaining, response.chunk())
|
||||
.await
|
||||
.map_err(|_| invalid("the response timed out"))?
|
||||
.map_err(|_| invalid("the response body could not be read"))?;
|
||||
let Some(chunk) = chunk else { break };
|
||||
if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(invalid(
|
||||
"the response body is larger than the broker returns",
|
||||
));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, host = %host, status, redirects, "brokered fetch");
|
||||
Ok(json!({
|
||||
"status": status,
|
||||
"url": final_url,
|
||||
"redirected": redirects > 0,
|
||||
"headers": headers,
|
||||
"body_base64": STANDARD.encode(scrub(&bytes, &secrets)),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_config::OwnerRef;
|
||||
use codewhale_net::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
|
||||
use codewhale_secrets::InMemoryKeyringStore;
|
||||
use std::sync::Arc;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
const SECRET: &str = "sk-live-0123456789abcdef";
|
||||
|
||||
fn owner(plugin: &str) -> OwnerRef {
|
||||
OwnerRef {
|
||||
plugin_id: plugin.into(),
|
||||
generation: 1,
|
||||
owner_token: "t".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(plugin: &str, method: &str, input: Value) -> BrokerCall {
|
||||
BrokerCall {
|
||||
owner: owner(plugin),
|
||||
session_id: "s".into(),
|
||||
service: DshService::Network,
|
||||
method: method.into(),
|
||||
input,
|
||||
context: super::super::dsh_broker::BrokerContext::detached_for_test(
|
||||
&owner(plugin),
|
||||
DshService::Network,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn policy(allow: &[&str], deny: &[&str]) -> ToolContext {
|
||||
let policy = NetworkPolicy {
|
||||
default: Decision::Deny.into(),
|
||||
allow: allow.iter().map(|h| (*h).to_string()).collect(),
|
||||
deny: deny.iter().map(|h| (*h).to_string()).collect(),
|
||||
proxy: Vec::new(),
|
||||
proxy_fake_ip_cidrs: Vec::new(),
|
||||
audit: false,
|
||||
};
|
||||
ToolContext::new(std::path::PathBuf::from("."))
|
||||
.with_network_policy(NetworkPolicyDecider::new(policy, None))
|
||||
}
|
||||
|
||||
fn authority(hosts: &[&str], tool: ToolContext, vault: &Secrets) -> NetworkAuthority {
|
||||
NetworkAuthority {
|
||||
hosts: hosts.iter().map(|h| (*h).to_string()).collect(),
|
||||
tool: Some(tool),
|
||||
vault: Some(vault.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn vault() -> Secrets {
|
||||
Secrets::new(Arc::new(InMemoryKeyringStore::new()))
|
||||
}
|
||||
|
||||
fn code(result: &Result<Value, BrokerRefusal>) -> &'static str {
|
||||
result.as_ref().expect_err("a refusal").code()
|
||||
}
|
||||
|
||||
fn message(result: &Result<Value, BrokerRefusal>) -> String {
|
||||
format!("{:?}", result.as_ref().expect_err("a refusal"))
|
||||
}
|
||||
|
||||
struct Echo;
|
||||
impl Respond for Echo {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let auth = request
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("x-echo", auth.as_str())
|
||||
.set_body_string(format!("you sent {auth}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn withheld_authority_refuses_typed_and_unknown_methods_are_named() {
|
||||
let unknown = serve(call("a", "connect", json!({}))).await;
|
||||
assert_eq!(code(&unknown), "unknown_method");
|
||||
let bad = serve(call("a", "fetch", json!({"url": 1}))).await;
|
||||
assert_eq!(code(&bad), "invalid_input");
|
||||
for (method, input) in [
|
||||
("fetch", json!({"url": "https://example.com/"})),
|
||||
("credentials.resolve", json!({"ref": "K"})),
|
||||
("credentials.describe", json!({"ref": "K"})),
|
||||
("credentials.set", json!({"ref": "K", "value": "v"})),
|
||||
] {
|
||||
assert_eq!(
|
||||
code(&serve(call("a", method, input)).await),
|
||||
"not_available",
|
||||
"{method}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credentials_never_leave_rust_and_are_scoped_to_their_plugin() {
|
||||
let vault = vault();
|
||||
let tool = policy(&[], &[]);
|
||||
let set = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"credentials.set",
|
||||
json!({"ref": "OPENAI_API_KEY", "value": SECRET}),
|
||||
),
|
||||
authority(&[], tool.clone(), &vault),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!set.to_string().contains(SECRET));
|
||||
let resolved = serve_with(
|
||||
call("a", "credentials.resolve", json!({"ref": "OPENAI_API_KEY"})),
|
||||
authority(&[], tool.clone(), &vault),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved["value"], "codewhale-credential:OPENAI_API_KEY");
|
||||
assert!(!resolved.to_string().contains(SECRET));
|
||||
let foreign = serve_with(
|
||||
call("b", "credentials.resolve", json!({"ref": "OPENAI_API_KEY"})),
|
||||
authority(&[], tool.clone(), &vault),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(foreign["value"], Value::Null);
|
||||
let traversal = serve_with(
|
||||
call(
|
||||
"b",
|
||||
"credentials.describe",
|
||||
json!({"ref": "../a:OPENAI_API_KEY"}),
|
||||
),
|
||||
authority(&[], tool, &vault),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code(&traversal), "invalid_input");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_host_gets_the_secret_and_the_wire_back_never_carries_it() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", format!("Bearer {SECRET}").as_str()))
|
||||
.respond_with(Echo)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let vault = vault();
|
||||
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
|
||||
let result = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({
|
||||
"url": format!("{}/v1/models", server.uri()),
|
||||
"headers": [["authorization", "Bearer codewhale-credential:KEY"]],
|
||||
}),
|
||||
),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["status"], 200);
|
||||
let wire = result.to_string();
|
||||
assert!(!wire.contains(SECRET), "{wire}");
|
||||
let body = STANDARD
|
||||
.decode(result["body_base64"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(body).unwrap(),
|
||||
"you sent Bearer codewhale-credential:KEY"
|
||||
);
|
||||
assert!(
|
||||
result["headers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|pair| pair[0] == "x-echo" && pair[1] == "Bearer codewhale-credential:KEY")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreviewed_denied_and_restricted_hosts_are_refused_before_any_connection() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let vault = vault();
|
||||
let url = format!("{}/x", server.uri());
|
||||
let unreviewed = serve_with(
|
||||
call("a", "fetch", json!({"url": url})),
|
||||
authority(&["api.example.com"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code(&unreviewed), "invalid_input");
|
||||
assert!(message(&unreviewed).contains("reviewed network hosts"));
|
||||
let denied = serve_with(
|
||||
call("a", "fetch", json!({"url": url})),
|
||||
authority(&["127.0.0.1"], policy(&[], &["127.0.0.1"]), &vault),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
message(&denied).contains("blocked by network policy"),
|
||||
"{}",
|
||||
message(&denied)
|
||||
);
|
||||
let unapproved = serve_with(
|
||||
call("a", "fetch", json!({"url": url})),
|
||||
authority(&["127.0.0.1"], policy(&[], &[]), &vault),
|
||||
)
|
||||
.await;
|
||||
assert!(message(&unapproved).contains("egress refused"));
|
||||
let metadata = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": "http://169.254.169.254/latest"}),
|
||||
),
|
||||
authority(
|
||||
&["169.254.169.254"],
|
||||
policy(&["169.254.169.254"], &[]),
|
||||
&vault,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
message(&metadata).contains("restricted"),
|
||||
"{}",
|
||||
message(&metadata)
|
||||
);
|
||||
let rebound = serve_with(
|
||||
call("a", "fetch", json!({"url": "http://localhost/"})),
|
||||
authority(
|
||||
&["localhost"],
|
||||
ToolContext::new(std::path::PathBuf::from(".")),
|
||||
&vault,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
message(&rebound).contains("localhost"),
|
||||
"{}",
|
||||
message(&rebound)
|
||||
);
|
||||
let in_url = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{url}?k=codewhale-credential:KEY")}),
|
||||
),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code(&in_url), "invalid_input");
|
||||
let host_header = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": url, "headers": [["Host", "evil.example"]]}),
|
||||
),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code(&host_header), "invalid_input");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redirects_stay_on_origin_and_never_carry_the_secret_elsewhere() {
|
||||
let server = MockServer::start().await;
|
||||
let elsewhere = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&elsewhere)
|
||||
.await;
|
||||
Mock::given(path("/away"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(302)
|
||||
.insert_header("location", format!("{}/steal", elsewhere.uri()).as_str()),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/rename"))
|
||||
.respond_with(ResponseTemplate::new(302).insert_header(
|
||||
"location",
|
||||
server.uri().replace("127.0.0.1", "localhost").as_str(),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/hop"))
|
||||
.respond_with(ResponseTemplate::new(307).insert_header("location", "/landing"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/landing"))
|
||||
.and(header("authorization", SECRET))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let vault = vault();
|
||||
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
|
||||
let headers = json!([["authorization", "codewhale-credential:KEY"]]);
|
||||
let both = authority(
|
||||
&["127.0.0.1", "localhost"],
|
||||
policy(&["127.0.0.1", "localhost"], &[]),
|
||||
&vault,
|
||||
);
|
||||
let away = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{}/away", server.uri()), "headers": headers}),
|
||||
),
|
||||
both,
|
||||
)
|
||||
.await;
|
||||
assert!(message(&away).contains("may not leave the requested origin"));
|
||||
let renamed = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{}/rename", server.uri()), "headers": headers}),
|
||||
),
|
||||
authority(
|
||||
&["127.0.0.1", "localhost"],
|
||||
policy(&["127.0.0.1", "localhost"], &[]),
|
||||
&vault,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(message(&renamed).contains("may not leave the requested origin"));
|
||||
let hop = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{}/hop", server.uri()), "headers": headers}),
|
||||
),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hop["status"], 200);
|
||||
assert_eq!(hop["redirected"], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Remote)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Settings)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Terminal)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Workspace)
|
||||
}
|
||||
@@ -122,6 +122,14 @@
|
||||
pub mod command;
|
||||
pub mod composition_review;
|
||||
pub mod core_call;
|
||||
pub mod dsh_broker;
|
||||
mod dsh_catalog;
|
||||
mod dsh_llm;
|
||||
mod dsh_network;
|
||||
mod dsh_remote;
|
||||
mod dsh_settings;
|
||||
mod dsh_terminal;
|
||||
mod dsh_workspace;
|
||||
pub mod execution;
|
||||
pub use execution::StockOperation;
|
||||
pub mod avatars;
|
||||
@@ -1011,6 +1019,23 @@ impl HostEvents for Events {
|
||||
.serve_checkpoint(&shared, self.tier, self.generation, params, cx)
|
||||
.await
|
||||
}
|
||||
protocol::HostRequest::DshBroker(params) => {
|
||||
let Some(shared) = self.shared.upgrade() else {
|
||||
return Err(refuse("extension host manager is gone"));
|
||||
};
|
||||
if shared
|
||||
.tier_runtime(self.tier)
|
||||
.host_generation
|
||||
.load(Ordering::SeqCst)
|
||||
!= self.generation
|
||||
{
|
||||
return Err(refuse("stale host generation"));
|
||||
}
|
||||
shared
|
||||
.core_calls
|
||||
.serve_broker(&shared, self.tier, self.generation, params, cx)
|
||||
.await
|
||||
}
|
||||
protocol::HostRequest::CoreCall(params) => {
|
||||
let Some(shared) = self.shared.upgrade() else {
|
||||
return Err(refuse("extension host manager is gone"));
|
||||
|
||||
@@ -176,6 +176,7 @@ pub const METHODS: &[MethodSpec] = &[
|
||||
row(Direction::HostToCore, "subagent/start", true),
|
||||
row(Direction::HostToCore, "subagent/result", true),
|
||||
row(Direction::HostToCore, "subagent/dispose", true),
|
||||
row(Direction::HostToCore, "dsh/broker", true),
|
||||
MethodSpec {
|
||||
tiers: &[HostTier::Builtin],
|
||||
..row(Direction::HostToCore, "proc/launch", true)
|
||||
@@ -530,6 +531,66 @@ pub struct CoreCallParams {
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DshService {
|
||||
Workspace,
|
||||
Llm,
|
||||
Network,
|
||||
Settings,
|
||||
Catalog,
|
||||
Terminal,
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl DshService {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Workspace,
|
||||
Self::Llm,
|
||||
Self::Network,
|
||||
Self::Settings,
|
||||
Self::Catalog,
|
||||
Self::Terminal,
|
||||
Self::Remote,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspace",
|
||||
Self::Llm => "llm",
|
||||
Self::Network => "network",
|
||||
Self::Settings => "settings",
|
||||
Self::Catalog => "catalog",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Remote => "remote",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|service| service.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DshBrokerParams {
|
||||
pub owner: OwnerRef,
|
||||
pub ticket: String,
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DshBrokerTicketWire {
|
||||
pub service: String,
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -764,6 +825,7 @@ pub enum HostRequest {
|
||||
SubagentStart(SubagentStartParams),
|
||||
SubagentResult(SubagentHandleParams),
|
||||
SubagentDispose(SubagentHandleParams),
|
||||
DshBroker(DshBrokerParams),
|
||||
ExecutionRedeem(ExecutionRedeemParams),
|
||||
ProcClose(ProcSessionParams),
|
||||
NetStart(ProcLaunchParams),
|
||||
@@ -788,6 +850,7 @@ impl HostRequest {
|
||||
Self::UserQuestion(params) => ¶ms.owner.plugin_id,
|
||||
Self::SubagentStart(params) => ¶ms.owner.plugin_id,
|
||||
Self::SubagentResult(params) | Self::SubagentDispose(params) => ¶ms.owner.plugin_id,
|
||||
Self::DshBroker(params) => ¶ms.owner.plugin_id,
|
||||
Self::ExecutionRedeem(params) => ¶ms.owner.plugin_id,
|
||||
Self::ProcClose(params) => ¶ms.owner.plugin_id,
|
||||
Self::NetClose(params) => ¶ms.owner.plugin_id,
|
||||
@@ -921,6 +984,10 @@ pub fn parse_host_message(value: Value, tier: HostTier) -> Result<HostMessage, P
|
||||
id,
|
||||
request: Box::new(HostRequest::SubagentDispose(params(&method, p)?)),
|
||||
},
|
||||
("dsh/broker", Some(id)) => HostMessage::Request {
|
||||
id,
|
||||
request: Box::new(HostRequest::DshBroker(params(&method, p)?)),
|
||||
},
|
||||
("session/checkpoint", Some(id)) => HostMessage::Request {
|
||||
id,
|
||||
request: Box::new(HostRequest::SessionCheckpoint(params(&method, p)?)),
|
||||
@@ -1040,6 +1107,7 @@ impl HostMessage {
|
||||
HostRequest::SessionCheckpoint(p) => {
|
||||
request_value(*id, "session/checkpoint", to_value(p))
|
||||
}
|
||||
HostRequest::DshBroker(p) => request_value(*id, "dsh/broker", to_value(p)),
|
||||
HostRequest::ProcClose(p) => request_value(*id, "proc/close", to_value(p)),
|
||||
HostRequest::NetStart(p) => request_value(*id, "net/start", to_value(p)),
|
||||
HostRequest::NetFetch(p) => request_value(*id, "net/fetch", to_value(p)),
|
||||
@@ -1145,6 +1213,8 @@ pub struct ToolCallParams {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_ticket: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub broker_tickets: Option<Vec<DshBrokerTicketWire>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub caller_tools: Option<Vec<String>>,
|
||||
/// Per-invocation identity; never cached at activation or by the host root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -56,6 +56,7 @@ fn params_schema(method: &str, generator: &mut SchemaGenerator) -> Schema {
|
||||
"user-questions/ask" => generator.subschema_for::<UserQuestionParams>(),
|
||||
"subagent/start" => generator.subschema_for::<SubagentStartParams>(),
|
||||
"subagent/result" | "subagent/dispose" => generator.subschema_for::<SubagentHandleParams>(),
|
||||
"dsh/broker" => generator.subschema_for::<DshBrokerParams>(),
|
||||
"ext/faulted" => generator.subschema_for::<FaultedParams>(),
|
||||
"log" => generator.subschema_for::<LogParams>(),
|
||||
other => panic!("method `{other}` has no params type in the TypeScript generator"),
|
||||
@@ -195,6 +196,15 @@ fn render() -> String {
|
||||
let _ = writeln!(out, " {name}: {code},");
|
||||
}
|
||||
out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
|
||||
let services: Vec<String> = DshService::ALL
|
||||
.iter()
|
||||
.map(|service| quote(service.name()))
|
||||
.collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\nexport const DSH_SERVICES = [{}] as const\nexport type DshService = typeof DSH_SERVICES[number]",
|
||||
services.join(", ")
|
||||
);
|
||||
out.push_str("\n/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */\nexport const METHODS = [\n");
|
||||
for (spec, params) in &methods {
|
||||
let tiers: Vec<String> = spec.tiers.iter().map(|tier| quote(tier.name())).collect();
|
||||
@@ -275,6 +285,11 @@ const REVIEWED: &[(&str, &str, &str)] = &[
|
||||
"subagent/dispose",
|
||||
"opaque live ticket and a child id this invocation started; asks the manager to cancel and dispose its own child; no other child, session or turn",
|
||||
),
|
||||
(
|
||||
"host_to_core",
|
||||
"dsh/broker",
|
||||
"opaque live invocation ticket minted per plugin owner, DSH service and session; Rust redeems it under the same owner, tier, host-generation, lifetime and use checks as core/call, takes the session from the ticket and never from the host, and dispatches only to that service's typed Rust arm, which refuses an unknown method or an unimplemented service; no tool name, approval, credential or model route",
|
||||
),
|
||||
(
|
||||
"host_to_core",
|
||||
"user-questions/ask",
|
||||
@@ -623,3 +638,63 @@ fn production_methods_follow_their_tier_rows_and_reserved_families_stay_builtin_
|
||||
HostTier::Builtin
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
|
||||
let frame = json!({
|
||||
"jsonrpc": "2.0", "id": 91, "method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {"plugin_id": "dsh-owner", "owner_token": "opaque", "generation": 1},
|
||||
"ticket": "broker-ticket", "service": "workspace", "method": "read", "input": {"path": "a"},
|
||||
},
|
||||
});
|
||||
for tier in HostTier::ALL {
|
||||
let parsed = parse_host_message(frame.clone(), tier).unwrap();
|
||||
assert_eq!(parsed.to_value(), frame);
|
||||
let HostMessage::Request { request, .. } = parsed else {
|
||||
panic!("dsh/broker is a request")
|
||||
};
|
||||
let HostRequest::DshBroker(params) = *request else {
|
||||
panic!("wrong broker variant")
|
||||
};
|
||||
assert_eq!(params.service, "workspace");
|
||||
assert!(parse_core_message(frame.clone(), tier).is_err());
|
||||
for field in ["session_id", "call_id", "approval", "name"] {
|
||||
let mut forged = frame.clone();
|
||||
forged["params"][field] = json!("foreign-selector");
|
||||
assert!(parse_host_message(forged, tier).is_err(), "{field}");
|
||||
}
|
||||
for field in ["owner", "ticket", "service", "method", "input"] {
|
||||
let mut missing = frame.clone();
|
||||
missing["params"].as_object_mut().unwrap().remove(field);
|
||||
assert!(parse_host_message(missing, tier).is_err(), "{field}");
|
||||
}
|
||||
let mut notification = frame.clone();
|
||||
notification.as_object_mut().unwrap().remove("id");
|
||||
assert!(parse_host_message(notification, tier).is_err());
|
||||
let mut unknown = frame.clone();
|
||||
unknown["params"]["service"] = json!("vault");
|
||||
assert!(parse_host_message(unknown, tier).is_ok());
|
||||
}
|
||||
let names: Vec<&str> = DshService::ALL
|
||||
.iter()
|
||||
.map(|service| service.name())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"workspace",
|
||||
"llm",
|
||||
"network",
|
||||
"settings",
|
||||
"catalog",
|
||||
"terminal",
|
||||
"remote"
|
||||
]
|
||||
);
|
||||
for service in DshService::ALL {
|
||||
assert_eq!(DshService::parse(service.name()), Some(service));
|
||||
}
|
||||
assert_eq!(DshService::parse("Workspace"), None);
|
||||
assert_eq!(DshService::parse("vault"), None);
|
||||
}
|
||||
@@ -1404,6 +1404,11 @@ pub fn registry_host_request<E: HostEvents + ?Sized>(
|
||||
message: "session/checkpoint is not served here".to_string(),
|
||||
data: None,
|
||||
}),
|
||||
HostRequest::DshBroker(_) => Err(RpcErrorWire {
|
||||
code: error_code::NOT_AVAILABLE,
|
||||
message: "dsh/broker is not served here".to_string(),
|
||||
data: Some(json!({ "dsh_refusal": "not_available" })),
|
||||
}),
|
||||
HostRequest::ExecutionRedeem(_)
|
||||
| HostRequest::CoreCall(_)
|
||||
| HostRequest::ProcLaunch(_)
|
||||
|
||||
@@ -2503,6 +2503,7 @@ async fn ordinary_exit_rejects_requests_from_a_drained_calls_waker() {
|
||||
checkpoint_ticket: None,
|
||||
question_ticket: None,
|
||||
subagent_ticket: None,
|
||||
broker_tickets: None,
|
||||
caller_tools: None,
|
||||
session_id: None,
|
||||
agent_id: None,
|
||||
@@ -2567,6 +2568,7 @@ async fn idle_retirement_seals_admission_and_does_not_wait_for_heartbeat() {
|
||||
checkpoint_ticket: None,
|
||||
question_ticket: None,
|
||||
subagent_ticket: None,
|
||||
broker_tickets: None,
|
||||
caller_tools: None,
|
||||
session_id: None,
|
||||
agent_id: None,
|
||||
@@ -2667,6 +2669,7 @@ async fn two_dirty_teardowns_wait_for_a_live_call_then_replay_without_spending_c
|
||||
checkpoint_ticket: None,
|
||||
question_ticket: None,
|
||||
subagent_ticket: None,
|
||||
broker_tickets: None,
|
||||
caller_tools: None,
|
||||
session_id: None,
|
||||
agent_id: None,
|
||||
|
||||
@@ -382,6 +382,7 @@ impl ToolSpec for HostToolSpec {
|
||||
subagent_ticket: invocation
|
||||
.as_ref()
|
||||
.and_then(|i| i.subagent_ticket().map(str::to_string)),
|
||||
broker_tickets: invocation.as_ref().and_then(|i| i.broker_tickets()),
|
||||
caller_tools: invocation.as_ref().and_then(|i| i.caller_tools()),
|
||||
session_id: context
|
||||
.execution
|
||||
|
||||
+697
-22
@@ -17,11 +17,12 @@
|
||||
//! connection rather than being parsed.
|
||||
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, sync_channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -32,6 +33,21 @@ const METHOD_NOT_FOUND: i64 = -32601;
|
||||
pub const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const AUTH_REQUIRED: i64 = -32000;
|
||||
const ABORT_POLL: Duration = Duration::from_millis(25);
|
||||
pub const MAX_CONTROLS: usize = 32;
|
||||
pub const MAX_CHOICES: usize = 256;
|
||||
pub const MAX_VALUE_BYTES: usize = 128;
|
||||
const MAX_NAME_CHARS: usize = 200;
|
||||
const MAX_DESCRIPTION_CHARS: usize = 400;
|
||||
|
||||
pub mod refusal {
|
||||
pub const CONTROL_NOT_OFFERED: &str = "control_not_offered";
|
||||
pub const CONTROL_UNAVAILABLE: &str = "control_unavailable";
|
||||
pub const CHOICE_NOT_OFFERED: &str = "choice_not_offered";
|
||||
pub const CHOICE_REJECTED: &str = "choice_rejected";
|
||||
pub const UNSUPPORTED_CONTROL: &str = "unsupported_control";
|
||||
pub const CONTROL_TYPE_UNSUPPORTED: &str = "control_type_unsupported";
|
||||
pub const CONTROL_HAS_NO_CHOICES: &str = "control_has_no_choices";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionPolicy {
|
||||
@@ -117,6 +133,471 @@ pub struct AgentIdentity {
|
||||
pub protocol_version: u64,
|
||||
pub load_session: bool,
|
||||
pub resume_session: bool,
|
||||
pub capabilities: AgentCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentCapabilities {
|
||||
pub load_session: bool,
|
||||
pub resume_session: bool,
|
||||
pub fork_session: bool,
|
||||
pub list_sessions: bool,
|
||||
pub additional_directories: bool,
|
||||
pub prompt_image: bool,
|
||||
pub prompt_audio: bool,
|
||||
pub prompt_embedded_context: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Choice {
|
||||
pub value: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ControlKind {
|
||||
Model,
|
||||
Mode,
|
||||
Config,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "method")]
|
||||
pub enum ControlWrite {
|
||||
#[serde(rename = "session/set_config_option")]
|
||||
SetConfigOption { config_id: String },
|
||||
#[serde(rename = "session/set_model")]
|
||||
SetModel,
|
||||
#[serde(rename = "session/set_mode")]
|
||||
SetMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ControlRefusal {
|
||||
pub code: &'static str,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Control {
|
||||
pub id: String,
|
||||
pub kind: ControlKind,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current: Option<String>,
|
||||
pub choices: Vec<Choice>,
|
||||
pub truncated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub write: Option<ControlWrite>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unavailable: Option<ControlRefusal>,
|
||||
}
|
||||
|
||||
impl Control {
|
||||
pub fn offers(&self, value: &str) -> bool {
|
||||
self.unavailable.is_none() && self.choices.iter().any(|choice| choice.value == value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct SessionOffer {
|
||||
pub controls: Vec<Control>,
|
||||
pub malformed_controls: usize,
|
||||
}
|
||||
|
||||
impl SessionOffer {
|
||||
pub fn control(&self, id: &str) -> Option<&Control> {
|
||||
self.controls.iter().find(|control| control.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ActionSupport {
|
||||
pub action: &'static str,
|
||||
pub supported: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_reports: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OptionsAgent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub protocol_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentOptions {
|
||||
pub agent: OptionsAgent,
|
||||
pub cwd: String,
|
||||
pub capabilities: AgentCapabilities,
|
||||
pub controls: Vec<Control>,
|
||||
pub malformed_controls: usize,
|
||||
pub actions: Vec<ActionSupport>,
|
||||
}
|
||||
|
||||
impl AgentOptions {
|
||||
pub fn new(identity: &AgentIdentity, id: &str, cwd: &Path, offer: SessionOffer) -> Self {
|
||||
let actions = action_support(&identity.capabilities, &offer);
|
||||
Self {
|
||||
agent: OptionsAgent {
|
||||
id: id.to_string(),
|
||||
name: identity.name.clone(),
|
||||
version: identity.version.clone(),
|
||||
protocol_version: identity.protocol_version,
|
||||
},
|
||||
cwd: cwd.display().to_string(),
|
||||
capabilities: identity.capabilities.clone(),
|
||||
controls: offer.controls,
|
||||
malformed_controls: offer.malformed_controls,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_support(capabilities: &AgentCapabilities, offer: &SessionOffer) -> Vec<ActionSupport> {
|
||||
let offered = |kind: ControlKind| {
|
||||
offer
|
||||
.controls
|
||||
.iter()
|
||||
.any(|control| control.kind == kind && control.unavailable.is_none())
|
||||
};
|
||||
let choice = |action, kind| {
|
||||
let supported = offered(kind);
|
||||
ActionSupport {
|
||||
action,
|
||||
supported,
|
||||
code: (!supported).then_some(refusal::CONTROL_NOT_OFFERED),
|
||||
reason: (!supported).then_some("The agent did not offer this control."),
|
||||
agent_reports: None,
|
||||
}
|
||||
};
|
||||
let refused = |action, reason, agent_reports| ActionSupport {
|
||||
action,
|
||||
supported: false,
|
||||
code: Some(refusal::UNSUPPORTED_CONTROL),
|
||||
reason: Some(reason),
|
||||
agent_reports,
|
||||
};
|
||||
vec![
|
||||
choice("model_choice", ControlKind::Model),
|
||||
choice("mode_choice", ControlKind::Mode),
|
||||
choice("config_choice", ControlKind::Config),
|
||||
refused(
|
||||
"steering",
|
||||
"Agent Client Protocol has no way to steer a running turn; interrupt it and send a new message.",
|
||||
None,
|
||||
),
|
||||
refused(
|
||||
"compaction",
|
||||
"Codewhale cannot compact another agent's context.",
|
||||
None,
|
||||
),
|
||||
refused(
|
||||
"fork",
|
||||
"Codewhale does not fork another agent's session.",
|
||||
Some(capabilities.fork_session),
|
||||
),
|
||||
refused(
|
||||
"images",
|
||||
"Codewhale sends text prompts to another agent only.",
|
||||
Some(capabilities.prompt_image),
|
||||
),
|
||||
refused(
|
||||
"dynamic_tools",
|
||||
"The agent uses its own tools; Codewhale tools cannot be added.",
|
||||
None,
|
||||
),
|
||||
refused(
|
||||
"output_limit",
|
||||
"Codewhale cannot cap another agent's output tokens.",
|
||||
None,
|
||||
),
|
||||
refused(
|
||||
"provider_override",
|
||||
"Codewhale providers do not apply; choose the agent's own model.",
|
||||
None,
|
||||
),
|
||||
refused(
|
||||
"remote_placement",
|
||||
"The agent runs on this computer only.",
|
||||
None,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub struct NewSession {
|
||||
pub session_id: String,
|
||||
pub offer: SessionOffer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentTokenUsage {
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub thought_tokens: Option<u64>,
|
||||
pub cached_read_tokens: Option<u64>,
|
||||
pub cached_write_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
impl AgentTokenUsage {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
fn from_result(result: &Value) -> Option<Self> {
|
||||
let usage = result.get("usage")?.as_object()?;
|
||||
let field = |name: &str| usage.get(name).and_then(Value::as_u64);
|
||||
let parsed = Self {
|
||||
input_tokens: field("inputTokens"),
|
||||
output_tokens: field("outputTokens"),
|
||||
total_tokens: field("totalTokens"),
|
||||
thought_tokens: field("thoughtTokens"),
|
||||
cached_read_tokens: field("cachedReadTokens"),
|
||||
cached_write_tokens: field("cachedWriteTokens"),
|
||||
};
|
||||
(!parsed.is_empty()).then_some(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PromptReply {
|
||||
pub stop_reason: String,
|
||||
pub usage: Option<AgentTokenUsage>,
|
||||
}
|
||||
|
||||
fn bounded_text(value: Option<&Value>, max_chars: usize) -> Option<String> {
|
||||
let text: String = value?
|
||||
.as_str()?
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.take(max_chars)
|
||||
.collect();
|
||||
let text = text.trim();
|
||||
(!text.is_empty()).then(|| text.to_string())
|
||||
}
|
||||
|
||||
fn offered_value(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?.as_str()?;
|
||||
(!value.is_empty() && value.len() <= MAX_VALUE_BYTES && !value.chars().any(char::is_control))
|
||||
.then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn push_choice(
|
||||
choices: &mut Vec<Choice>,
|
||||
truncated: &mut bool,
|
||||
value: Option<String>,
|
||||
name: Option<&Value>,
|
||||
description: Option<&Value>,
|
||||
) {
|
||||
let Some(value) = value else {
|
||||
*truncated = true;
|
||||
return;
|
||||
};
|
||||
if choices.iter().any(|choice| choice.value == value) {
|
||||
return;
|
||||
}
|
||||
if choices.len() >= MAX_CHOICES {
|
||||
*truncated = true;
|
||||
return;
|
||||
}
|
||||
choices.push(Choice {
|
||||
name: bounded_text(name, MAX_NAME_CHARS).unwrap_or_else(|| value.clone()),
|
||||
description: bounded_text(description, MAX_DESCRIPTION_CHARS),
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
fn config_choices(options: &[Value]) -> (Vec<Choice>, bool) {
|
||||
let mut choices = Vec::new();
|
||||
let mut truncated = false;
|
||||
for option in options {
|
||||
if let Some(group) = option.get("options").and_then(Value::as_array) {
|
||||
for grouped in group {
|
||||
push_choice(
|
||||
&mut choices,
|
||||
&mut truncated,
|
||||
offered_value(grouped.get("value")),
|
||||
grouped.get("name"),
|
||||
grouped.get("description"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
push_choice(
|
||||
&mut choices,
|
||||
&mut truncated,
|
||||
offered_value(option.get("value")),
|
||||
option.get("name"),
|
||||
option.get("description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
(choices, truncated)
|
||||
}
|
||||
|
||||
impl SessionOffer {
|
||||
pub fn from_session_result(result: &Value) -> Self {
|
||||
let mut offer = Self::default();
|
||||
let mut model_claimed = false;
|
||||
let mut mode_claimed = false;
|
||||
let options = result
|
||||
.get("configOptions")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
for option in options {
|
||||
if offer.controls.len() >= MAX_CONTROLS {
|
||||
offer.malformed_controls += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(config_id) = offered_value(option.get("id")) else {
|
||||
offer.malformed_controls += 1;
|
||||
continue;
|
||||
};
|
||||
let category = bounded_text(option.get("category"), 64);
|
||||
let kind = if !model_claimed
|
||||
&& (config_id == "model" || category.as_deref() == Some("model"))
|
||||
{
|
||||
model_claimed = true;
|
||||
ControlKind::Model
|
||||
} else if !mode_claimed && (config_id == "mode" || category.as_deref() == Some("mode"))
|
||||
{
|
||||
mode_claimed = true;
|
||||
ControlKind::Mode
|
||||
} else {
|
||||
ControlKind::Config
|
||||
};
|
||||
let id = match kind {
|
||||
ControlKind::Model => "model".to_string(),
|
||||
ControlKind::Mode => "mode".to_string(),
|
||||
ControlKind::Config => format!("config:{config_id}"),
|
||||
};
|
||||
if offer.control(&id).is_some() {
|
||||
offer.malformed_controls += 1;
|
||||
continue;
|
||||
}
|
||||
let is_select = option.get("type").and_then(Value::as_str) == Some("select");
|
||||
let (choices, truncated) = if is_select {
|
||||
config_choices(
|
||||
option
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), false)
|
||||
};
|
||||
let unavailable = if !is_select {
|
||||
Some(ControlRefusal {
|
||||
code: refusal::CONTROL_TYPE_UNSUPPORTED,
|
||||
reason: "Codewhale can only write select-type agent options.".to_string(),
|
||||
})
|
||||
} else if choices.is_empty() {
|
||||
Some(ControlRefusal {
|
||||
code: refusal::CONTROL_HAS_NO_CHOICES,
|
||||
reason: "The agent offered this option with no choices.".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
offer.controls.push(Control {
|
||||
name: bounded_text(option.get("name"), MAX_NAME_CHARS)
|
||||
.unwrap_or_else(|| config_id.clone()),
|
||||
description: bounded_text(option.get("description"), MAX_DESCRIPTION_CHARS),
|
||||
category,
|
||||
current: offered_value(option.get("currentValue")),
|
||||
write: unavailable
|
||||
.is_none()
|
||||
.then_some(ControlWrite::SetConfigOption { config_id }),
|
||||
id,
|
||||
kind,
|
||||
choices,
|
||||
truncated,
|
||||
unavailable,
|
||||
});
|
||||
}
|
||||
if !model_claimed
|
||||
&& let Some(models) = result.get("models").filter(|value| value.is_object())
|
||||
{
|
||||
offer.push_legacy(
|
||||
ControlKind::Model,
|
||||
models.get("currentModelId"),
|
||||
models.get("availableModels"),
|
||||
"modelId",
|
||||
ControlWrite::SetModel,
|
||||
);
|
||||
}
|
||||
if !mode_claimed && let Some(modes) = result.get("modes").filter(|value| value.is_object())
|
||||
{
|
||||
offer.push_legacy(
|
||||
ControlKind::Mode,
|
||||
modes.get("currentModeId"),
|
||||
modes.get("availableModes"),
|
||||
"id",
|
||||
ControlWrite::SetMode,
|
||||
);
|
||||
}
|
||||
offer
|
||||
}
|
||||
|
||||
fn push_legacy(
|
||||
&mut self,
|
||||
kind: ControlKind,
|
||||
current: Option<&Value>,
|
||||
available: Option<&Value>,
|
||||
value_key: &str,
|
||||
write: ControlWrite,
|
||||
) {
|
||||
let mut choices = Vec::new();
|
||||
let mut truncated = false;
|
||||
for entry in available
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
push_choice(
|
||||
&mut choices,
|
||||
&mut truncated,
|
||||
offered_value(entry.get(value_key)),
|
||||
entry.get("name"),
|
||||
entry.get("description"),
|
||||
);
|
||||
}
|
||||
if choices.is_empty() {
|
||||
self.malformed_controls += 1;
|
||||
return;
|
||||
}
|
||||
let (id, name) = match kind {
|
||||
ControlKind::Model => ("model", "Model"),
|
||||
_ => ("mode", "Mode"),
|
||||
};
|
||||
self.controls.push(Control {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
category: None,
|
||||
current: offered_value(current),
|
||||
choices,
|
||||
truncated,
|
||||
write: Some(write),
|
||||
unavailable: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -222,6 +703,26 @@ impl std::fmt::Debug for AcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_params(
|
||||
identity: &AgentIdentity,
|
||||
session: Option<&str>,
|
||||
cwd: &Path,
|
||||
add_dirs: &[PathBuf],
|
||||
) -> Value {
|
||||
let mut params = json!({"cwd": cwd.display().to_string(), "mcpServers": []});
|
||||
if let Some(session) = session {
|
||||
params["sessionId"] = json!(session);
|
||||
}
|
||||
if identity.capabilities.additional_directories && !add_dirs.is_empty() {
|
||||
params["additionalDirectories"] = add_dirs
|
||||
.iter()
|
||||
.map(|dir| dir.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn write_line(stdin: &Mutex<std::process::ChildStdin>, message: &Value) -> Result<(), AcpError> {
|
||||
write_line_unless_cancelled(stdin, message, None)
|
||||
}
|
||||
@@ -415,11 +916,12 @@ impl AcpClient {
|
||||
} else {
|
||||
PermissionAnswer::Cancelled
|
||||
};
|
||||
write_line_unless_cancelled(
|
||||
&self.stdin,
|
||||
&json!({"jsonrpc": "2.0", "id": request_id, "result": {"outcome": answer.outcome()}}),
|
||||
cancel,
|
||||
)?;
|
||||
let answer = if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
PermissionAnswer::Cancelled
|
||||
} else {
|
||||
answer
|
||||
};
|
||||
self.write(&json!({"jsonrpc": "2.0", "id": request_id, "result": {"outcome": answer.outcome()}}))?;
|
||||
}
|
||||
("elicitation/create", Some(request_id)) => {
|
||||
self.write(&json!({"jsonrpc": "2.0", "id": request_id, "result": {"action": "decline"}}))?;
|
||||
@@ -456,7 +958,26 @@ impl AcpClient {
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|version| *version == 1)
|
||||
.ok_or_else(|| AcpError::new("the agent did not negotiate ACP protocol version 1"))?;
|
||||
let flag = |pointer: &str| {
|
||||
capabilities
|
||||
.pointer(pointer)
|
||||
.is_some_and(|value| !value.is_null() && value != &Value::Bool(false))
|
||||
};
|
||||
let capability_report = AgentCapabilities {
|
||||
load_session: capabilities
|
||||
.get("loadSession")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
resume_session: flag("/sessionCapabilities/resume"),
|
||||
fork_session: flag("/sessionCapabilities/fork"),
|
||||
list_sessions: flag("/sessionCapabilities/list"),
|
||||
additional_directories: flag("/sessionCapabilities/additionalDirectories"),
|
||||
prompt_image: flag("/promptCapabilities/image"),
|
||||
prompt_audio: flag("/promptCapabilities/audio"),
|
||||
prompt_embedded_context: flag("/promptCapabilities/embeddedContext"),
|
||||
};
|
||||
Ok(AgentIdentity {
|
||||
capabilities: capability_report,
|
||||
name: info
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
@@ -478,21 +999,31 @@ impl AcpClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_session(&mut self, cwd: &Path, timeout: Duration) -> Result<String, AcpError> {
|
||||
pub fn new_session(
|
||||
&mut self,
|
||||
identity: &AgentIdentity,
|
||||
cwd: &Path,
|
||||
add_dirs: &[PathBuf],
|
||||
timeout: Duration,
|
||||
) -> Result<NewSession, AcpError> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let result = self.request(
|
||||
"session/new",
|
||||
json!({"cwd": cwd.display().to_string(), "mcpServers": []}),
|
||||
session_params(identity, None, cwd, add_dirs),
|
||||
None,
|
||||
deadline,
|
||||
&mut DiscardHandler,
|
||||
None,
|
||||
)?;
|
||||
result
|
||||
let session_id = result
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| AcpError::new("session/new did not name a session"))
|
||||
.ok_or_else(|| AcpError::new("session/new did not name a session"))?;
|
||||
Ok(NewSession {
|
||||
session_id,
|
||||
offer: SessionOffer::from_session_result(&result),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reopen_session(
|
||||
@@ -500,8 +1031,9 @@ impl AcpClient {
|
||||
identity: &AgentIdentity,
|
||||
session: &str,
|
||||
cwd: &Path,
|
||||
add_dirs: &[PathBuf],
|
||||
timeout: Duration,
|
||||
) -> Result<(), AcpError> {
|
||||
) -> Result<SessionOffer, AcpError> {
|
||||
let method = if identity.resume_session {
|
||||
"session/resume"
|
||||
} else if identity.load_session {
|
||||
@@ -514,7 +1046,45 @@ impl AcpClient {
|
||||
let deadline = Instant::now() + timeout;
|
||||
self.request(
|
||||
method,
|
||||
json!({"sessionId": session, "cwd": cwd.display().to_string(), "mcpServers": []}),
|
||||
session_params(identity, Some(session), cwd, add_dirs),
|
||||
Some(session),
|
||||
deadline,
|
||||
&mut DiscardHandler,
|
||||
None,
|
||||
)
|
||||
.map(|result| SessionOffer::from_session_result(&result))
|
||||
}
|
||||
|
||||
pub fn apply_control(
|
||||
&mut self,
|
||||
session: &str,
|
||||
control: &Control,
|
||||
value: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(), AcpError> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let (method, params) = match control.write.as_ref() {
|
||||
Some(ControlWrite::SetConfigOption { config_id }) => (
|
||||
"session/set_config_option",
|
||||
json!({"sessionId": session, "configId": config_id, "value": value}),
|
||||
),
|
||||
Some(ControlWrite::SetModel) => (
|
||||
"session/set_model",
|
||||
json!({"sessionId": session, "modelId": value}),
|
||||
),
|
||||
Some(ControlWrite::SetMode) => (
|
||||
"session/set_mode",
|
||||
json!({"sessionId": session, "modeId": value}),
|
||||
),
|
||||
None => {
|
||||
return Err(AcpError::new(
|
||||
"the agent did not offer this control for writing",
|
||||
));
|
||||
}
|
||||
};
|
||||
self.request(
|
||||
method,
|
||||
params,
|
||||
Some(session),
|
||||
deadline,
|
||||
&mut DiscardHandler,
|
||||
@@ -549,7 +1119,7 @@ impl AcpClient {
|
||||
timeout: Duration,
|
||||
handler: &mut dyn AcpHandler,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> Result<String, AcpError> {
|
||||
) -> Result<PromptReply, AcpError> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let result = self.request(
|
||||
"session/prompt",
|
||||
@@ -559,12 +1129,16 @@ impl AcpClient {
|
||||
handler,
|
||||
cancel,
|
||||
)?;
|
||||
result
|
||||
let stop_reason = result
|
||||
.get("stopReason")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|reason| !reason.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| AcpError::new("session/prompt did not report a stop reason"))
|
||||
.ok_or_else(|| AcpError::new("session/prompt did not report a stop reason"))?;
|
||||
Ok(PromptReply {
|
||||
stop_reason,
|
||||
usage: AgentTokenUsage::from_result(&result),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prompt(
|
||||
@@ -575,7 +1149,7 @@ impl AcpClient {
|
||||
timeout: Duration,
|
||||
) -> Result<PromptOutcome, AcpError> {
|
||||
let mut outcome = PromptOutcome::default();
|
||||
let stop_reason = self.prompt_with(
|
||||
let reply = self.prompt_with(
|
||||
session,
|
||||
text,
|
||||
timeout,
|
||||
@@ -585,7 +1159,7 @@ impl AcpClient {
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
outcome.stop_reason = stop_reason;
|
||||
outcome.stop_reason = reply.stop_reason;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
@@ -644,6 +1218,104 @@ mod tests {
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_offer_is_parsed_bounded_and_never_widened() {
|
||||
let many: Vec<Value> = (0..(MAX_CHOICES + 40))
|
||||
.map(|index| json!({"value": format!("v{index}"), "name": "x".repeat(900)}))
|
||||
.collect();
|
||||
let offer = SessionOffer::from_session_result(&json!({
|
||||
"configOptions": [
|
||||
{"id": "model", "name": "Model", "category": "model", "type": "select",
|
||||
"currentValue": "a",
|
||||
"options": [{"group": "g", "name": "G", "options": [
|
||||
{"value": "a", "name": "A"}, {"value": "a", "name": "dup"}, {"value": "b", "name": "B", "description": "d"}]},
|
||||
{"value": "c", "name": "C"}]},
|
||||
{"id": "flag", "name": "Flag", "type": "boolean", "currentValue": true},
|
||||
{"id": "none", "name": "None", "type": "select", "options": []},
|
||||
{"id": "", "name": "No id", "type": "select", "options": []},
|
||||
{"id": "big", "name": "Big", "type": "select", "currentValue": "v0", "options": many},
|
||||
{"id": "bad\nid", "name": "Control char", "type": "select", "options": [{"value": "x"}]}
|
||||
],
|
||||
"modes": {"currentModeId": "m1", "availableModes": [{"id": "m1", "name": "M1"}, {"id": "", "name": "bad"}]},
|
||||
"models": {"currentModelId": "legacy", "availableModels": [{"modelId": "legacy", "name": "Legacy"}]}
|
||||
}));
|
||||
let ids: Vec<&str> = offer.controls.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["model", "config:flag", "config:none", "config:big", "mode"]
|
||||
);
|
||||
assert_eq!(offer.malformed_controls, 2);
|
||||
let model = offer.control("model").unwrap();
|
||||
let values: Vec<&str> = model.choices.iter().map(|c| c.value.as_str()).collect();
|
||||
assert_eq!(
|
||||
values,
|
||||
["a", "b", "c"],
|
||||
"groups flatten and duplicates collapse"
|
||||
);
|
||||
assert_eq!(model.choices[1].description.as_deref(), Some("d"));
|
||||
assert!(model.offers("c") && !model.offers("legacy") && !model.offers(""));
|
||||
assert_eq!(
|
||||
offer
|
||||
.control("config:flag")
|
||||
.unwrap()
|
||||
.unavailable
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.code,
|
||||
refusal::CONTROL_TYPE_UNSUPPORTED
|
||||
);
|
||||
assert!(!offer.control("config:flag").unwrap().offers("true"));
|
||||
assert_eq!(
|
||||
offer
|
||||
.control("config:none")
|
||||
.unwrap()
|
||||
.unavailable
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.code,
|
||||
refusal::CONTROL_HAS_NO_CHOICES
|
||||
);
|
||||
let big = offer.control("config:big").unwrap();
|
||||
assert_eq!(big.choices.len(), MAX_CHOICES);
|
||||
assert!(big.truncated);
|
||||
assert!(big.choices[0].name.chars().count() <= MAX_NAME_CHARS);
|
||||
assert!(!big.offers(&format!("v{}", MAX_CHOICES + 1)));
|
||||
let mode = offer.control("mode").unwrap();
|
||||
assert_eq!(mode.write, Some(ControlWrite::SetMode));
|
||||
assert_eq!(mode.choices.len(), 1);
|
||||
assert!(
|
||||
mode.truncated,
|
||||
"a dropped malformed choice marks the list incomplete"
|
||||
);
|
||||
|
||||
let legacy = SessionOffer::from_session_result(&json!({
|
||||
"models": {"currentModelId": "legacy", "availableModels": [{"modelId": "legacy", "name": "Legacy"}]}
|
||||
}));
|
||||
assert_eq!(
|
||||
legacy.control("model").unwrap().write,
|
||||
Some(ControlWrite::SetModel)
|
||||
);
|
||||
assert!(
|
||||
SessionOffer::from_session_result(&json!({}))
|
||||
.controls
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_usage_keeps_only_what_the_agent_reported() {
|
||||
let usage = AgentTokenUsage::from_result(&json!({
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 5, "outputTokens": "many", "totalTokens": 9}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(usage.input_tokens, Some(5));
|
||||
assert_eq!(usage.output_tokens, None);
|
||||
assert_eq!(usage.total_tokens, Some(9));
|
||||
assert!(AgentTokenUsage::from_result(&json!({"stopReason": "end_turn"})).is_none());
|
||||
assert!(AgentTokenUsage::from_result(&json!({"usage": {}})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_json_rpc_worker_speaks_acp_end_to_end() {
|
||||
let Some(_node) = node() else {
|
||||
@@ -676,8 +1348,9 @@ mod tests {
|
||||
assert!(identity.load_session);
|
||||
|
||||
let session = client
|
||||
.new_session(workspace.path(), Duration::from_secs(20))
|
||||
.unwrap();
|
||||
.new_session(&identity, workspace.path(), &[], Duration::from_secs(20))
|
||||
.unwrap()
|
||||
.session_id;
|
||||
let allowed = client
|
||||
.prompt(
|
||||
&session,
|
||||
@@ -725,6 +1398,7 @@ mod tests {
|
||||
&identity,
|
||||
&session,
|
||||
workspace.path(),
|
||||
&[],
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -818,10 +1492,11 @@ mod tests {
|
||||
|
||||
let io = host.take_worker_io("w-acp").expect("duplex io");
|
||||
let mut client = AcpClient::new(io);
|
||||
client.initialize(Duration::from_secs(20)).unwrap();
|
||||
let identity = client.initialize(Duration::from_secs(20)).unwrap();
|
||||
let session = client
|
||||
.new_session(workspace.path(), Duration::from_secs(20))
|
||||
.unwrap();
|
||||
.new_session(&identity, workspace.path(), &[], Duration::from_secs(20))
|
||||
.unwrap()
|
||||
.session_id;
|
||||
let canceller = client.canceller(&session);
|
||||
let prompt_session = session.clone();
|
||||
let blocked = std::thread::spawn(move || {
|
||||
|
||||
@@ -520,10 +520,145 @@ pub async fn launch(id: &str) -> Result<ExternalAgentLaunch, ExecutorUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct QuotaGate {
|
||||
pub enabled: bool,
|
||||
pub consented: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaSource {
|
||||
AgentReported,
|
||||
LocalSignin,
|
||||
}
|
||||
|
||||
impl QuotaSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AgentReported => "agent_reported",
|
||||
Self::LocalSignin => "local_signin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaUnknown {
|
||||
FlagOff,
|
||||
ConsentMissing,
|
||||
NotImplemented,
|
||||
UnknownAgent,
|
||||
}
|
||||
|
||||
impl QuotaUnknown {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::FlagOff => "flag_off",
|
||||
Self::ConsentMissing => "consent_missing",
|
||||
Self::NotImplemented => "not_implemented",
|
||||
Self::UnknownAgent => "unknown_agent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuotaWindow {
|
||||
pub name: String,
|
||||
pub used_percent: Option<f64>,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AgentQuota {
|
||||
Known {
|
||||
source: QuotaSource,
|
||||
windows: Vec<QuotaWindow>,
|
||||
},
|
||||
Unknown(QuotaUnknown),
|
||||
}
|
||||
|
||||
pub enum LocalSigninReader {}
|
||||
|
||||
fn local_signin_reader(_agent: &ExternalAgent) -> Option<LocalSigninReader> {
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn local_signin_quota(agent: &ExternalAgent, gate: QuotaGate) -> AgentQuota {
|
||||
if !gate.enabled {
|
||||
return AgentQuota::Unknown(QuotaUnknown::FlagOff);
|
||||
}
|
||||
if !gate.consented {
|
||||
return AgentQuota::Unknown(QuotaUnknown::ConsentMissing);
|
||||
}
|
||||
match local_signin_reader(agent) {
|
||||
None => AgentQuota::Unknown(QuotaUnknown::NotImplemented),
|
||||
Some(reader) => match reader {},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn quota_gate_reads_nothing_without_flag_and_consent_and_logs_nothing() {
|
||||
let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
|
||||
let writer = buffer.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::TRACE)
|
||||
.with_writer(move || CaptureWriter(writer.clone()))
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
let cases = [
|
||||
(QuotaGate::default(), QuotaUnknown::FlagOff),
|
||||
(
|
||||
QuotaGate {
|
||||
enabled: false,
|
||||
consented: true,
|
||||
},
|
||||
QuotaUnknown::FlagOff,
|
||||
),
|
||||
(
|
||||
QuotaGate {
|
||||
enabled: true,
|
||||
consented: false,
|
||||
},
|
||||
QuotaUnknown::ConsentMissing,
|
||||
),
|
||||
(
|
||||
QuotaGate {
|
||||
enabled: true,
|
||||
consented: true,
|
||||
},
|
||||
QuotaUnknown::NotImplemented,
|
||||
),
|
||||
];
|
||||
for agent in AGENTS {
|
||||
assert!(local_signin_reader(agent).is_none(), "{}", agent.id);
|
||||
for (gate, reason) in cases {
|
||||
assert_eq!(
|
||||
local_signin_quota(agent, gate).await,
|
||||
AgentQuota::Unknown(reason),
|
||||
"{} {gate:?}",
|
||||
agent.id
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(buffer.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for CaptureWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn names(paths: &[PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
|
||||
@@ -70,6 +70,10 @@ impl AccountFence {
|
||||
}
|
||||
|
||||
async fn read_account_epoch(profile: Option<String>) -> Option<AccountEpoch> {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(seam) = TEST_ACCOUNT_EPOCH.with(|cell| cell.borrow().clone()) {
|
||||
return seam;
|
||||
}
|
||||
tokio::task::spawn_blocking(move || account_epoch(profile.as_deref()))
|
||||
.await
|
||||
.ok()?
|
||||
@@ -271,6 +275,43 @@ pub fn fail_account_load_for_test(message: &'static str) -> AccountLoadFailureGu
|
||||
AccountLoadFailureGuard
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_ACCOUNT_EPOCH: std::cell::RefCell<Option<Option<AccountEpoch>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn pin_account_epoch_for_test(account_id: Option<&str>) -> AccountEpochGuard {
|
||||
let guard = AccountEpochGuard;
|
||||
guard.set(account_id);
|
||||
guard
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub struct AccountEpochGuard;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl AccountEpochGuard {
|
||||
pub fn set(&self, account_id: Option<&str>) {
|
||||
let epoch = account_id.map(|id| AccountEpoch {
|
||||
api_base: runtime_account_api_base(),
|
||||
state: AccountSessionState::Authenticated,
|
||||
account_id: Some(id.to_owned()),
|
||||
session_id: Some(format!("session_{id}")),
|
||||
signed_out_revision: None,
|
||||
});
|
||||
TEST_ACCOUNT_EPOCH.with(|cell| *cell.borrow_mut() = Some(epoch));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl Drop for AccountEpochGuard {
|
||||
fn drop(&mut self) {
|
||||
TEST_ACCOUNT_EPOCH.with(|cell| *cell.borrow_mut() = None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub struct AccountLoadFailureGuard;
|
||||
|
||||
@@ -384,4 +425,62 @@ mod tests {
|
||||
document.notes = "unsafe\0control".into();
|
||||
assert!(document.as_user_constitution().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_browser_call_is_fenced_to_the_admitting_account() {
|
||||
const BROWSER: &str = "mcp_codewhale-browser_navigate";
|
||||
const OTHER: &str = "mcp_other_navigate";
|
||||
let refused = |result: Result<(), crate::tools::spec::ToolError>| {
|
||||
result.expect_err("call must be refused").to_string()
|
||||
};
|
||||
|
||||
let epoch = pin_account_epoch_for_test(Some("acct_a"));
|
||||
assert!(
|
||||
refused(admit_desktop_browser_call(BROWSER, None).await)
|
||||
.contains("could not confirm which account started this turn")
|
||||
);
|
||||
|
||||
let fence = AccountFence::capture(None).await;
|
||||
admit_desktop_browser_call(BROWSER, Some(&fence))
|
||||
.await
|
||||
.expect("unchanged epoch is admitted");
|
||||
admit_desktop_browser_call(OTHER, Some(&fence))
|
||||
.await
|
||||
.expect("non-browser tool is unaffected");
|
||||
admit_desktop_browser_call("read_file", None)
|
||||
.await
|
||||
.expect("non-browser tool needs no fence");
|
||||
|
||||
epoch.set(Some("acct_b"));
|
||||
assert!(
|
||||
refused(admit_desktop_browser_call(BROWSER, Some(&fence)).await)
|
||||
.contains("account changed after this turn started")
|
||||
);
|
||||
admit_desktop_browser_call(OTHER, Some(&fence))
|
||||
.await
|
||||
.expect("non-browser tool still unaffected after a switch");
|
||||
let fence_b = AccountFence::capture(None).await;
|
||||
admit_desktop_browser_call(BROWSER, Some(&fence_b))
|
||||
.await
|
||||
.expect("a turn admitted under the new account is admitted");
|
||||
epoch.set(Some("acct_a"));
|
||||
assert!(
|
||||
refused(admit_desktop_browser_call(BROWSER, Some(&fence_b)).await)
|
||||
.contains("account changed after this turn started")
|
||||
);
|
||||
|
||||
epoch.set(None);
|
||||
assert!(
|
||||
refused(admit_desktop_browser_call(BROWSER, Some(&fence_b)).await)
|
||||
.contains("could not confirm the signed-in account")
|
||||
);
|
||||
let unconfirmed = AccountFence::capture(None).await;
|
||||
assert!(
|
||||
refused(admit_desktop_browser_call(BROWSER, Some(&unconfirmed)).await)
|
||||
.contains("could not confirm which account started this turn")
|
||||
);
|
||||
admit_desktop_browser_call(OTHER, Some(&unconfirmed))
|
||||
.await
|
||||
.expect("non-browser tool unaffected by an unreadable store");
|
||||
}
|
||||
}
|
||||
@@ -234,3 +234,210 @@ impl JobOperationTransaction<'_> {
|
||||
write_json_atomic(&self.path(&record.operation.id)?, record)
|
||||
}
|
||||
}
|
||||
|
||||
pub const MEMBER_RESERVED: &str = "reserved";
|
||||
pub const MEMBER_ACCEPTED: &str = "accepted";
|
||||
pub const MEMBER_ABANDONED: &str = "abandoned";
|
||||
pub const MEMBER_UNKNOWN: &str = "unknown";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemberOperation {
|
||||
pub id: String,
|
||||
pub store_id: String,
|
||||
pub thread_id: String,
|
||||
pub member_id: String,
|
||||
pub operation_key: String,
|
||||
pub agent_id: String,
|
||||
pub state: String,
|
||||
pub created_at_ms: i64,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemberOperationRecord {
|
||||
pub schema_version: u32,
|
||||
pub operation: MemberOperation,
|
||||
pub request_fingerprint: String,
|
||||
pub owner_generation: String,
|
||||
}
|
||||
|
||||
pub fn valid_member_key(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
|
||||
}
|
||||
|
||||
pub fn member_request_fingerprint(
|
||||
thread_id: &str,
|
||||
member_id: &str,
|
||||
display_name: &str,
|
||||
prompt: &str,
|
||||
fleet: &impl Serialize,
|
||||
) -> Result<String> {
|
||||
Ok(codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(
|
||||
&(
|
||||
"runtime-member-request-v1",
|
||||
thread_id,
|
||||
member_id,
|
||||
display_name,
|
||||
prompt,
|
||||
fleet,
|
||||
),
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn new_member_agent_id() -> String {
|
||||
format!("agent_{}", &Uuid::new_v4().simple().to_string()[..12])
|
||||
}
|
||||
|
||||
impl JobOperationTransaction<'_> {
|
||||
fn member_dir(&self) -> Result<PathBuf> {
|
||||
let dir = self
|
||||
.store
|
||||
.job_operations_dir
|
||||
.parent()
|
||||
.context("missing runtime store root")?
|
||||
.join("member-operations");
|
||||
ensure_runtime_store_dir(&dir)?;
|
||||
checked_existing_runtime_store_dir(&dir)
|
||||
}
|
||||
|
||||
pub fn member_operation_id(&self, thread_id: &str, key: &str) -> Result<String> {
|
||||
validated_record_id(thread_id, "member operation thread")?;
|
||||
anyhow::ensure!(valid_member_key(key), "invalid member operation key");
|
||||
Ok(codewhale_runtime::hashing::sha256_hex(format!(
|
||||
"runtime-member-operation\u{1f}{}\u{1f}{thread_id}\u{1f}{key}",
|
||||
self.store.owner_id
|
||||
)))
|
||||
}
|
||||
|
||||
fn member_path(&self, id: &str) -> Result<PathBuf> {
|
||||
validate_sha256_fingerprint(id, "member operation id")?;
|
||||
RuntimeThreadStore::record_path(&self.member_dir()?, id, "json", "member operation id")
|
||||
}
|
||||
|
||||
fn validate_member(&self, record: &MemberOperationRecord) -> Result<()> {
|
||||
let operation = &record.operation;
|
||||
anyhow::ensure!(
|
||||
record.schema_version == 1,
|
||||
"unsupported member operation schema"
|
||||
);
|
||||
validate_sha256_fingerprint(&operation.id, "member operation id")?;
|
||||
validate_sha256_fingerprint(&record.request_fingerprint, "member request fingerprint")?;
|
||||
anyhow::ensure!(
|
||||
operation.id
|
||||
== self.member_operation_id(&operation.thread_id, &operation.operation_key)?,
|
||||
"member operation identity mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
operation.store_id == self.store.owner_id,
|
||||
"member operation store mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
valid_member_key(&operation.member_id),
|
||||
"invalid member operation member"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
operation.agent_id.starts_with("agent_")
|
||||
&& validated_record_id(&operation.agent_id, "member agent id").is_ok()
|
||||
&& operation.agent_id.len() <= 64,
|
||||
"invalid member agent id"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
operation.state.as_str(),
|
||||
MEMBER_RESERVED | MEMBER_ACCEPTED | MEMBER_ABANDONED
|
||||
),
|
||||
"invalid member operation state"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
operation.created_at_ms > 0 && operation.updated_at_ms >= operation.created_at_ms,
|
||||
"invalid member operation timestamps"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
Uuid::parse_str(&record.owner_generation).is_ok_and(|id| id.get_version_num() == 4),
|
||||
"invalid member owner generation"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_member(&self, id: &str) -> Result<Option<MemberOperationRecord>> {
|
||||
let path = self.member_path(id)?;
|
||||
let raw = match read_store_file(&path) {
|
||||
Ok(raw) => raw,
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<io::Error>()
|
||||
.is_some_and(|error| error.kind() == io::ErrorKind::NotFound) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let record: MemberOperationRecord = serde_json::from_str(&raw)?;
|
||||
self.validate_member(&record)?;
|
||||
anyhow::ensure!(record.operation.id == id, "member operation path mismatch");
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
pub fn list_members(&self) -> Result<Vec<MemberOperationRecord>> {
|
||||
let directory = self.member_dir()?;
|
||||
let mut records = Vec::new();
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_none_or(|ext| ext != "json") {
|
||||
continue;
|
||||
}
|
||||
let id = path
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("invalid member operation filename")?;
|
||||
records.push(
|
||||
self.get_member(id)?
|
||||
.context("member operation disappeared")?,
|
||||
);
|
||||
}
|
||||
records.sort_by(|a, b| {
|
||||
a.operation
|
||||
.created_at_ms
|
||||
.cmp(&b.operation.created_at_ms)
|
||||
.then_with(|| a.operation.id.cmp(&b.operation.id))
|
||||
});
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn save_member(&self, record: &MemberOperationRecord) -> Result<()> {
|
||||
self.validate_member(record)?;
|
||||
write_json_atomic(&self.member_path(&record.operation.id)?, record)
|
||||
}
|
||||
|
||||
pub fn remove_member(&self, id: &str) -> Result<()> {
|
||||
remove_file_if_exists(&self.member_path(id)?)
|
||||
}
|
||||
|
||||
pub fn thread_has_pending_member_operation(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
generation: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
Ok(self.list_members()?.iter().any(|record| {
|
||||
record.operation.thread_id == thread_id
|
||||
&& record.operation.state == MEMBER_RESERVED
|
||||
&& generation.is_none_or(|generation| record.owner_generation == generation)
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn remove_thread_members(&self, thread_id: &str) -> Result<()> {
|
||||
for record in self.list_members()? {
|
||||
if record.operation.thread_id == thread_id {
|
||||
self.remove_member(&record.operation.id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -7439,6 +7439,7 @@ impl RuntimeThreadManager {
|
||||
if active.engines.contains_key(thread_id) {
|
||||
bail!("cannot discard a loaded Runtime thread");
|
||||
}
|
||||
let generation = self.job_operation_generation().ok();
|
||||
self.store.with_job_operations(|transaction| {
|
||||
anyhow::ensure!(
|
||||
!transaction
|
||||
@@ -7448,6 +7449,11 @@ impl RuntimeThreadManager {
|
||||
&& !matches!(record.operation.status.as_str(), "completed" | "rejected")),
|
||||
"thread has a running or unconfirmed job operation"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!transaction
|
||||
.thread_has_pending_member_operation(thread_id, generation.as_deref())?,
|
||||
"thread has a pending member operation"
|
||||
);
|
||||
let _thread_mutation = self.store.thread_mutation.lock();
|
||||
let thread = self.store.load_thread(thread_id)?;
|
||||
if thread.latest_turn_id.is_some() {
|
||||
@@ -7455,6 +7461,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
active.shell_managers.remove(thread_id);
|
||||
self.store.remove_thread(thread_id)?;
|
||||
transaction.remove_thread_members(thread_id)?;
|
||||
Ok(())
|
||||
})?;
|
||||
drop(active);
|
||||
@@ -7476,6 +7483,7 @@ impl RuntimeThreadManager {
|
||||
{
|
||||
bail!("thread {thread_id} already has an active turn; stop it before deleting");
|
||||
}
|
||||
let generation = self.job_operation_generation().ok();
|
||||
let (thread, evicted, shell_manager) = self.store.with_job_operations(|transaction| {
|
||||
anyhow::ensure!(
|
||||
!transaction
|
||||
@@ -7485,6 +7493,11 @@ impl RuntimeThreadManager {
|
||||
&& !matches!(record.operation.status.as_str(), "completed" | "rejected")),
|
||||
"thread has a running or unconfirmed job operation"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!transaction
|
||||
.thread_has_pending_member_operation(thread_id, generation.as_deref())?,
|
||||
"thread has a pending member operation"
|
||||
);
|
||||
let _thread_mutation = self.store.thread_mutation.lock();
|
||||
let thread = self.store.load_thread(thread_id)?;
|
||||
active.lru.retain(|id| id != thread_id);
|
||||
@@ -7501,6 +7514,7 @@ impl RuntimeThreadManager {
|
||||
remove_file_if_exists(&self.store.goal_path(thread_id)?)?;
|
||||
self.store.remove_seed_journal(thread_id)?;
|
||||
self.store.remove_thread(thread_id)?;
|
||||
transaction.remove_thread_members(thread_id)?;
|
||||
Ok((thread, (evicted, external), shell_manager))
|
||||
})?;
|
||||
drop(active);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
|
||||
use super::*;
|
||||
use crate::fleet::acp::{AcpCanceller, AcpClient, AcpHandler, AgentIdentity, PermissionAnswer};
|
||||
use crate::fleet::acp::{
|
||||
AcpCanceller, AcpClient, AcpError, AcpHandler, AgentIdentity, AgentOptions, AgentTokenUsage,
|
||||
PermissionAnswer, PromptReply, SessionOffer, refusal,
|
||||
};
|
||||
use crate::fleet::agents::ExternalAgentLaunch;
|
||||
use crate::fleet::host::{
|
||||
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
|
||||
@@ -19,6 +22,11 @@ const MAX_ID_BYTES: usize = 128;
|
||||
const MAX_TOOL_ITEMS_PER_TURN: usize = 512;
|
||||
const MAX_LOCATIONS: usize = 32;
|
||||
const MAX_OPTIONS: usize = 16;
|
||||
const MAX_CONCURRENT_OPTION_PROBES: usize = 4;
|
||||
static OPTIONS_PROBES: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_OPTION_PROBES))
|
||||
});
|
||||
const KILLED_REASON: &str = "The agent did not stop; Codewhale ended its process.";
|
||||
|
||||
pub(crate) struct ExternalThreadState {
|
||||
@@ -47,7 +55,7 @@ enum AgentSignal {
|
||||
params: Value,
|
||||
reply: std_mpsc::Sender<PermissionAnswer>,
|
||||
},
|
||||
Finished(Result<String, String>),
|
||||
Finished(Result<PromptReply, String>),
|
||||
}
|
||||
|
||||
pub(crate) struct ExternalSession {
|
||||
@@ -145,87 +153,204 @@ struct SessionStart {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
struct AgentProcess {
|
||||
host: Option<LocalProcessFleetHostAdapter>,
|
||||
worker_id: String,
|
||||
}
|
||||
|
||||
impl AgentProcess {
|
||||
fn spawn(
|
||||
log_root: &Path,
|
||||
worker_id: String,
|
||||
workspace: &Path,
|
||||
launch: &ExternalAgentLaunch,
|
||||
) -> Result<(Self, AcpClient)> {
|
||||
let mut host = LocalProcessFleetHostAdapter::new(log_root);
|
||||
let mut request = FleetWorkerStartRequest::new(
|
||||
worker_id.clone(),
|
||||
FleetWorkerCommand::new(launch.program.display().to_string(), launch.args.clone()),
|
||||
);
|
||||
request.transport = FleetWorkerTransport::JsonRpc;
|
||||
request.cwd = Some(workspace.to_path_buf());
|
||||
request.env_allowlist = launch.env.keys().cloned().collect();
|
||||
request.env = launch.env.clone();
|
||||
host.start_worker(request)
|
||||
.map_err(|error| anyhow!("{} could not start: {error}", launch.agent_name))?;
|
||||
let io = host.take_worker_io(&worker_id);
|
||||
let process = Self {
|
||||
host: Some(host),
|
||||
worker_id,
|
||||
};
|
||||
let Some(io) = io else {
|
||||
bail!("{} started without a protocol channel", launch.agent_name);
|
||||
};
|
||||
Ok((process, AcpClient::new(io)))
|
||||
}
|
||||
|
||||
fn release(mut self) -> LocalProcessFleetHostAdapter {
|
||||
self.host.take().expect("an agent process is released once")
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
if let Some(mut host) = self.host.take()
|
||||
&& let Err(error) = host.stop_worker(&self.worker_id)
|
||||
{
|
||||
tracing::warn!(worker = %self.worker_id, %error, "stopping an external agent failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgentProcess {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_in_or(
|
||||
error: anyhow::Error,
|
||||
agent_name: &str,
|
||||
login: Option<&'static str>,
|
||||
) -> anyhow::Error {
|
||||
if error
|
||||
.downcast_ref::<AcpError>()
|
||||
.is_some_and(AcpError::is_auth_required)
|
||||
{
|
||||
let hint = login
|
||||
.map(|command| format!(" Run `{command}` in a terminal, then try again."))
|
||||
.unwrap_or_default();
|
||||
return crate::fleet::agents::ExecutorUnavailable::new(
|
||||
crate::fleet::agents::ExecutorUnavailable::NEEDS_SIGN_IN,
|
||||
format!("{agent_name} needs you to sign in to it first.{hint}"),
|
||||
)
|
||||
.into();
|
||||
}
|
||||
if error
|
||||
.downcast_ref::<crate::fleet::agents::ExecutorUnavailable>()
|
||||
.is_some()
|
||||
{
|
||||
return error;
|
||||
}
|
||||
anyhow!("{agent_name}: {error}")
|
||||
}
|
||||
|
||||
fn executor_choices(executor: &ExecutorRecord) -> Vec<(&'static str, String)> {
|
||||
executor
|
||||
.model
|
||||
.iter()
|
||||
.map(|model| ("model", model.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refuse(code: &'static str, message: impl Into<String>) -> anyhow::Error {
|
||||
crate::fleet::agents::ExecutorUnavailable::new(code, message).into()
|
||||
}
|
||||
|
||||
fn apply_choices(
|
||||
client: &mut AcpClient,
|
||||
session_id: &str,
|
||||
offer: &SessionOffer,
|
||||
choices: &[(&'static str, String)],
|
||||
) -> Result<()> {
|
||||
for (id, value) in choices {
|
||||
let shown = label(Some(&Value::String(value.clone())), "");
|
||||
if offer.controls.is_empty() && *id == "model" {
|
||||
client
|
||||
.set_config_option(session_id, "model", value, SETUP_TIMEOUT)
|
||||
.map_err(|error| rejected_choice(id, &shown, error))?;
|
||||
continue;
|
||||
}
|
||||
let Some(control) = offer.control(id) else {
|
||||
return Err(refuse(
|
||||
refusal::CONTROL_NOT_OFFERED,
|
||||
format!("The agent does not offer a {id} choice, so `{shown}` was not applied."),
|
||||
));
|
||||
};
|
||||
if let Some(why) = control.unavailable.as_ref() {
|
||||
return Err(refuse(refusal::CONTROL_UNAVAILABLE, why.reason.clone()));
|
||||
}
|
||||
if !control.offers(value) {
|
||||
return Err(refuse(
|
||||
refusal::CHOICE_NOT_OFFERED,
|
||||
format!(
|
||||
"The agent no longer offers `{shown}` for {id}; choose one of its current options."
|
||||
),
|
||||
));
|
||||
}
|
||||
if control.current.as_deref() == Some(value.as_str()) {
|
||||
continue;
|
||||
}
|
||||
client
|
||||
.apply_control(session_id, control, value, SETUP_TIMEOUT)
|
||||
.map_err(|error| rejected_choice(id, &shown, error))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rejected_choice(id: &str, shown: &str, error: AcpError) -> anyhow::Error {
|
||||
if error.is_auth_required() {
|
||||
return error.into();
|
||||
}
|
||||
refuse(
|
||||
refusal::CHOICE_REJECTED,
|
||||
format!("The agent refused {id} `{shown}`: {error}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn start_session_blocking(
|
||||
log_root: PathBuf,
|
||||
thread_id: String,
|
||||
workspace: PathBuf,
|
||||
add_dirs: Vec<PathBuf>,
|
||||
launch: ExternalAgentLaunch,
|
||||
executor: ExecutorRecord,
|
||||
login: Option<&'static str>,
|
||||
abandoned: CancellationToken,
|
||||
) -> Result<SessionStart> {
|
||||
let worker_id = format!("acp-{thread_id}");
|
||||
let mut host = LocalProcessFleetHostAdapter::new(&log_root);
|
||||
let mut request = FleetWorkerStartRequest::new(
|
||||
worker_id.clone(),
|
||||
FleetWorkerCommand::new(launch.program.display().to_string(), launch.args.clone()),
|
||||
);
|
||||
request.transport = FleetWorkerTransport::JsonRpc;
|
||||
request.cwd = Some(workspace.clone());
|
||||
request.env_allowlist = launch.env.keys().cloned().collect();
|
||||
request.env = launch.env.clone();
|
||||
host.start_worker(request)
|
||||
.map_err(|error| anyhow!("{} could not start: {error}", launch.agent_name))?;
|
||||
let Some(io) = host.take_worker_io(&worker_id) else {
|
||||
let _ = host.stop_worker(&worker_id);
|
||||
bail!("{} started without a protocol channel", launch.agent_name);
|
||||
};
|
||||
let mut client = AcpClient::new(io);
|
||||
let (process, mut client) =
|
||||
AgentProcess::spawn(&log_root, worker_id.clone(), &workspace, &launch)?;
|
||||
client.abort_requests_on(Some(abandoned));
|
||||
let setup = (|| {
|
||||
let choices = executor_choices(&executor);
|
||||
let setup = (|| -> Result<(AgentIdentity, String)> {
|
||||
let identity = client.initialize(SETUP_TIMEOUT)?;
|
||||
let session_id = match executor.foreign_session_id.as_deref() {
|
||||
let (session_id, offer) = match executor.foreign_session_id.as_deref() {
|
||||
Some(session_id) => {
|
||||
client.reopen_session(&identity, session_id, &workspace, SETUP_TIMEOUT)?;
|
||||
session_id.to_string()
|
||||
let offer = client.reopen_session(
|
||||
&identity,
|
||||
session_id,
|
||||
&workspace,
|
||||
&add_dirs,
|
||||
SETUP_TIMEOUT,
|
||||
)?;
|
||||
(session_id.to_string(), offer)
|
||||
}
|
||||
None => {
|
||||
let started =
|
||||
client.new_session(&identity, &workspace, &add_dirs, SETUP_TIMEOUT)?;
|
||||
(started.session_id, started.offer)
|
||||
}
|
||||
None => client.new_session(&workspace, SETUP_TIMEOUT)?,
|
||||
};
|
||||
if let Some(model) = executor.model.as_deref() {
|
||||
client.set_config_option(&session_id, "model", model, SETUP_TIMEOUT)?;
|
||||
}
|
||||
Ok::<_, crate::fleet::acp::AcpError>((identity, session_id))
|
||||
apply_choices(&mut client, &session_id, &offer, &choices)?;
|
||||
Ok((identity, session_id))
|
||||
})();
|
||||
let (identity, session_id) = match setup {
|
||||
Ok(setup) => setup,
|
||||
Err(error) => {
|
||||
let _ = host.stop_worker(&worker_id);
|
||||
if error.is_auth_required() {
|
||||
let hint = login
|
||||
.map(|command| format!(" Run `{command}` in a terminal, then try again."))
|
||||
.unwrap_or_default();
|
||||
return Err(crate::fleet::agents::ExecutorUnavailable::new(
|
||||
crate::fleet::agents::ExecutorUnavailable::NEEDS_SIGN_IN,
|
||||
format!(
|
||||
"{} needs you to sign in to it first.{hint}",
|
||||
launch.agent_name
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
bail!("{}: {error}", launch.agent_name);
|
||||
}
|
||||
};
|
||||
let (identity, session_id) =
|
||||
setup.map_err(|error| sign_in_or(error, &launch.agent_name, login))?;
|
||||
if !valid_id(&session_id) {
|
||||
let _ = host.stop_worker(&worker_id);
|
||||
bail!("{} named an invalid session id", launch.agent_name);
|
||||
}
|
||||
client.abort_requests_on(None);
|
||||
let canceller = client.canceller(&session_id);
|
||||
let (commands, receiver) = std_mpsc::channel();
|
||||
let session_for_thread = session_id.clone();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
std::thread::Builder::new()
|
||||
.name(format!("acp-{}", launch.agent_id))
|
||||
.spawn(move || run_session(client, session_for_thread, receiver))
|
||||
{
|
||||
let _ = host.stop_worker(&worker_id);
|
||||
bail!("starting the agent session thread failed: {error}");
|
||||
}
|
||||
.map_err(|error| anyhow!("starting the agent session thread failed: {error}"))?;
|
||||
Ok(SessionStart {
|
||||
session: ExternalSession {
|
||||
commands: parking_lot::Mutex::new(Some(commands)),
|
||||
canceller,
|
||||
host: parking_lot::Mutex::new(host),
|
||||
host: parking_lot::Mutex::new(process.release()),
|
||||
worker_id,
|
||||
},
|
||||
identity,
|
||||
@@ -233,6 +358,31 @@ fn start_session_blocking(
|
||||
})
|
||||
}
|
||||
|
||||
fn probe_options_blocking(
|
||||
log_root: PathBuf,
|
||||
workspace: PathBuf,
|
||||
launch: ExternalAgentLaunch,
|
||||
login: Option<&'static str>,
|
||||
abandoned: CancellationToken,
|
||||
) -> Result<AgentOptions> {
|
||||
let worker_id = format!("acp-options-{}", runtime_record_id("probe"));
|
||||
let (mut process, mut client) = AgentProcess::spawn(&log_root, worker_id, &workspace, &launch)?;
|
||||
client.abort_requests_on(Some(abandoned));
|
||||
let probed = (|| -> Result<AgentOptions> {
|
||||
let identity = client.initialize(SETUP_TIMEOUT)?;
|
||||
let started = client.new_session(&identity, &workspace, &[], SETUP_TIMEOUT)?;
|
||||
Ok(AgentOptions::new(
|
||||
&identity,
|
||||
&launch.agent_id,
|
||||
&workspace,
|
||||
started.offer,
|
||||
))
|
||||
})();
|
||||
drop(client);
|
||||
process.stop();
|
||||
probed.map_err(|error| sign_in_or(error, &launch.agent_name, login))
|
||||
}
|
||||
|
||||
fn valid_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_ID_BYTES
|
||||
@@ -394,6 +544,8 @@ struct ExternalProjection {
|
||||
plan: Option<TurnItemRecord>,
|
||||
title: Option<String>,
|
||||
failure: Option<String>,
|
||||
context: Option<(Option<u64>, Option<u64>)>,
|
||||
tokens: Option<AgentTokenUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -573,13 +725,14 @@ impl RuntimeThreadManager {
|
||||
.context("creating the external agent log directory failed")?;
|
||||
let thread_id = thread.id.clone();
|
||||
let workspace = thread.workspace.clone();
|
||||
let add_dirs = thread.add_dirs.clone();
|
||||
let starting = executor.clone();
|
||||
let login = agent.login;
|
||||
let abandoned = self.cancel_token.child_token();
|
||||
let _abandon = abandoned.clone().drop_guard();
|
||||
let (started, _loading) = tokio::task::spawn_blocking(move || {
|
||||
let started = start_session_blocking(
|
||||
log_root, thread_id, workspace, launch, starting, login, abandoned,
|
||||
log_root, thread_id, workspace, add_dirs, launch, starting, login, abandoned,
|
||||
);
|
||||
(started, loading)
|
||||
})
|
||||
@@ -637,6 +790,48 @@ impl RuntimeThreadManager {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn external_agent_options(&self, agent_id: &str, cwd: &Path) -> Result<AgentOptions> {
|
||||
self.ensure_accepting_execution()?;
|
||||
let agent = crate::fleet::agents::find(agent_id).ok_or_else(|| {
|
||||
crate::fleet::agents::ExecutorUnavailable::new(
|
||||
crate::fleet::agents::ExecutorUnavailable::UNKNOWN_AGENT,
|
||||
format!("No external agent is named `{agent_id}`."),
|
||||
)
|
||||
})?;
|
||||
let workspace = tokio::fs::canonicalize(cwd)
|
||||
.await
|
||||
.map_err(|error| anyhow!("cwd {} is unavailable: {error}", cwd.display()))?;
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&workspace)
|
||||
.await
|
||||
.is_ok_and(|metadata| metadata.is_dir()),
|
||||
"cwd {} is not a directory",
|
||||
workspace.display()
|
||||
);
|
||||
let launch = crate::fleet::agents::launch(agent.id).await?;
|
||||
let log_root = self
|
||||
.session_store_binding()
|
||||
.data_dir
|
||||
.join("external-agents");
|
||||
tokio::fs::create_dir_all(&log_root)
|
||||
.await
|
||||
.context("creating the external agent log directory failed")?;
|
||||
let abandoned = self.cancel_token.child_token();
|
||||
let _abandon = abandoned.clone().drop_guard();
|
||||
let permit = tokio::select! {
|
||||
permit = Arc::clone(&OPTIONS_PROBES).acquire_owned() => permit?,
|
||||
() = abandoned.cancelled() => bail!("Runtime is shutting down; execution admission is closed"),
|
||||
};
|
||||
self.ensure_accepting_execution()?;
|
||||
let login = agent.login;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
probe_options_blocking(log_root, workspace, launch, login, abandoned)
|
||||
})
|
||||
.await
|
||||
.context("external agent options worker failed")?
|
||||
}
|
||||
|
||||
pub(super) async fn external_thread_loaded(&self, thread_id: &str) -> bool {
|
||||
self.active.lock().await.external.contains_key(thread_id)
|
||||
}
|
||||
@@ -724,27 +919,36 @@ impl RuntimeThreadManager {
|
||||
matches!(input_source, RuntimeTurnInputSource::ExternalUser),
|
||||
"this kind of turn is not available in a session with another agent"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
req.images.is_empty(),
|
||||
"image inputs are not available in a session with another agent"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
req.dynamic_tools.is_empty(),
|
||||
"dynamic tools are not available in a session with another agent"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
req.model.is_none()
|
||||
&& req.reasoning_effort.is_none()
|
||||
&& req.max_output_tokens.is_none()
|
||||
&& req.model_provider.is_none()
|
||||
&& req.model_provider_id.is_none()
|
||||
&& req.account_model_owner.is_none(),
|
||||
"a session with another agent uses its saved agent model; per-turn model controls are unavailable"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
req.allowed_tools.is_none(),
|
||||
"a session with another agent uses that agent's own tools; tool allowlists are unavailable"
|
||||
);
|
||||
if !req.images.is_empty() {
|
||||
return Err(refuse(
|
||||
refusal::UNSUPPORTED_CONTROL,
|
||||
"Image inputs are not available in a session with another agent.",
|
||||
));
|
||||
}
|
||||
if !req.dynamic_tools.is_empty() {
|
||||
return Err(refuse(
|
||||
refusal::UNSUPPORTED_CONTROL,
|
||||
"Dynamic tools are not available in a session with another agent.",
|
||||
));
|
||||
}
|
||||
if req.model.is_some()
|
||||
|| req.reasoning_effort.is_some()
|
||||
|| req.max_output_tokens.is_some()
|
||||
|| req.model_provider.is_some()
|
||||
|| req.model_provider_id.is_some()
|
||||
|| req.account_model_owner.is_some()
|
||||
{
|
||||
return Err(refuse(
|
||||
refusal::UNSUPPORTED_CONTROL,
|
||||
"A session with another agent keeps its saved agent model; per-turn model, reasoning, output and provider controls are unavailable.",
|
||||
));
|
||||
}
|
||||
if req.allowed_tools.is_some() {
|
||||
return Err(refuse(
|
||||
refusal::UNSUPPORTED_CONTROL,
|
||||
"A session with another agent uses that agent's own tools; tool allowlists are unavailable.",
|
||||
));
|
||||
}
|
||||
if req.environment_id.is_some() {
|
||||
return Err(crate::fleet::agents::ExecutorUnavailable::new(
|
||||
crate::fleet::agents::ExecutorUnavailable::ENVIRONMENT_NOT_SUPPORTED,
|
||||
@@ -1041,10 +1245,21 @@ impl RuntimeThreadManager {
|
||||
};
|
||||
let _ = reply.send(answer);
|
||||
}
|
||||
Some(AgentSignal::Finished(result)) => break result,
|
||||
Some(AgentSignal::Finished(result)) => {
|
||||
break result.map(|reply| {
|
||||
projection.tokens = reply.usage;
|
||||
reply.stop_reason
|
||||
});
|
||||
}
|
||||
None => break Err("the agent session ended".to_string()),
|
||||
}
|
||||
};
|
||||
if let Err(error) = self
|
||||
.emit_agent_usage(&thread.id, &turn_id, &projection)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(thread_id = %thread.id, turn_id = %turn_id, %error, "recording an external agent's usage failed");
|
||||
}
|
||||
if let Err(error) = self
|
||||
.settle_external_turn(&thread, &turn_id, projection, outcome)
|
||||
.await
|
||||
@@ -1053,6 +1268,32 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_agent_usage(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
projection: &ExternalProjection,
|
||||
) -> Result<()> {
|
||||
let tokens = projection
|
||||
.tokens
|
||||
.as_ref()
|
||||
.filter(|tokens| !tokens.is_empty());
|
||||
let reported = tokens.is_some() || projection.context.is_some();
|
||||
let payload = json!({
|
||||
"kind": "turn_total",
|
||||
"source": if reported { "agent reported" } else { "none" },
|
||||
"tokens": tokens.map_or_else(|| json!("unknown"), |tokens| json!(tokens)),
|
||||
"context": projection.context.map_or_else(
|
||||
|| json!("unknown"),
|
||||
|(used, size)| json!({ "used": used, "size": size }),
|
||||
),
|
||||
"cost": "unknown",
|
||||
});
|
||||
self.emit_event(thread_id, Some(turn_id), None, "turn.agent_usage", payload)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_external_item(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -1228,7 +1469,7 @@ impl RuntimeThreadManager {
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some("plan") => {
|
||||
Some("plan" | "plan_update") => {
|
||||
let mut detail = String::new();
|
||||
for entry in update
|
||||
.get("entries")
|
||||
@@ -1265,16 +1506,53 @@ impl RuntimeThreadManager {
|
||||
let used = update.get("used").and_then(Value::as_u64);
|
||||
let size = update.get("size").and_then(Value::as_u64);
|
||||
if used.is_some() || size.is_some() {
|
||||
projection.context = Some((used, size));
|
||||
self.emit_event(
|
||||
thread_id,
|
||||
Some(turn_id),
|
||||
None,
|
||||
"turn.agent_usage",
|
||||
json!({ "used": used, "size": size, "source": "agent reported", "cost": "unknown" }),
|
||||
json!({ "kind": "context_window", "used": used, "size": size, "source": "agent reported", "cost": "unknown" }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some("current_mode_update") => {
|
||||
let mode = bounded_id(update.get("currentModeId").or_else(|| update.get("modeId")));
|
||||
if let Some(mode) = mode {
|
||||
self.emit_event(
|
||||
thread_id,
|
||||
Some(turn_id),
|
||||
None,
|
||||
"turn.agent_mode",
|
||||
json!({ "mode": mode, "source": "agent reported" }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some("available_commands_update") => {
|
||||
let commands: Vec<Value> = update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(64)
|
||||
.filter_map(|command| {
|
||||
let name = bounded_id(command.get("name"))?;
|
||||
let description = label(command.get("description"), "");
|
||||
Some(json!({ "name": name, "description": description }))
|
||||
})
|
||||
.collect();
|
||||
self.emit_event(
|
||||
thread_id,
|
||||
Some(turn_id),
|
||||
None,
|
||||
"turn.agent_commands",
|
||||
json!({ "commands": commands, "source": "agent reported" }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some("session_info_update") => {
|
||||
if let Some(title) = update.get("title").and_then(Value::as_str) {
|
||||
let title = label(Some(&Value::String(title.to_string())), "");
|
||||
|
||||
@@ -715,3 +715,545 @@ async fn an_abandoned_external_startup_stops_its_agent_inside_the_shutdown_barri
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn options_fixture() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp-options-agent.mjs")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn received(workspace: &Path) -> Vec<Value> {
|
||||
std::fs::read_to_string(workspace.join("options-received.jsonl"))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str(line).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn pid_alive(pid: u64) -> bool {
|
||||
std::process::Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn refusal_code(error: &anyhow::Error) -> Option<&'static str> {
|
||||
error
|
||||
.downcast_ref::<crate::fleet::agents::ExecutorUnavailable>()
|
||||
.map(|refusal| refusal.code)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn options_thread_request(workspace: &Path, model: &str) -> CreateThreadRequest {
|
||||
let mut request = fixture_thread_request(workspace, "ask");
|
||||
request.executor.as_mut().unwrap().config.model = Some(model.to_string());
|
||||
request
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn an_options_probe_reports_the_agents_own_offer_and_leaves_no_process() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
|
||||
let _secret = crate::test_support::EnvVarGuard::set("CODEWHALE_TEST_SECRET", "must-not-travel");
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
|
||||
let options = manager
|
||||
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
|
||||
.await?;
|
||||
let json = serde_json::to_value(&options)?;
|
||||
let controls: Vec<&str> = options.controls.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
controls,
|
||||
["model", "config:effort", "config:verbose", "mode"]
|
||||
);
|
||||
let model = &options.controls[0];
|
||||
assert_eq!(model.current.as_deref(), Some("model-a"));
|
||||
let values: Vec<&str> = model.choices.iter().map(|c| c.value.as_str()).collect();
|
||||
assert_eq!(values, ["model-a", "model-b", "model-c"]);
|
||||
assert_eq!(
|
||||
json["controls"][0]["write"]["method"],
|
||||
"session/set_config_option"
|
||||
);
|
||||
assert_eq!(json["controls"][3]["write"]["method"], "session/set_mode");
|
||||
let verbose = &options.controls[2];
|
||||
assert_eq!(
|
||||
verbose.unavailable.as_ref().map(|u| u.code),
|
||||
Some("control_type_unsupported")
|
||||
);
|
||||
assert!(verbose.write.is_none());
|
||||
assert_eq!(json["capabilities"]["prompt_image"], true);
|
||||
assert_eq!(json["capabilities"]["fork_session"], true);
|
||||
let action = |name: &str| {
|
||||
json["actions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|entry| entry["action"] == name)
|
||||
.cloned()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(action("model_choice")["supported"], true);
|
||||
assert_eq!(action("steering")["code"], "unsupported_control");
|
||||
assert_eq!(action("images")["supported"], false);
|
||||
assert_eq!(action("images")["agent_reports"], true);
|
||||
|
||||
let log = received(&workspace);
|
||||
let pid = log[0]["pid"].as_u64().expect("the probe agent's pid");
|
||||
assert!(!pid_alive(pid), "the probe's agent process was stopped");
|
||||
assert!(log.iter().all(|entry| entry["method"] != "session/prompt"));
|
||||
let dumped = std::fs::read_to_string(workspace.join("options-env.json"))?;
|
||||
assert!(!dumped.contains("CODEWHALE_TEST_SECRET"));
|
||||
|
||||
let wrong = manager
|
||||
.external_agent_options(FIXTURE_AGENT_ID, &workspace.join("missing"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(wrong.to_string().contains("unavailable"), "{wrong}");
|
||||
let unknown = manager
|
||||
.external_agent_options("nope", &workspace)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(refusal_code(&unknown), Some("unknown_agent"));
|
||||
|
||||
std::fs::write(
|
||||
workspace.join("options-fixture.json"),
|
||||
r#"{"auth_required":true}"#,
|
||||
)?;
|
||||
let unauthenticated = manager
|
||||
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(refusal_code(&unauthenticated), Some("needs_sign_in"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn an_options_probe_ends_when_the_runtime_shuts_down() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::fs::write(workspace.join("options-fixture.json"), r#"{"hang":true}"#)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let probing = manager.clone();
|
||||
let probe_workspace = workspace.clone();
|
||||
let probe = tokio::spawn(async move {
|
||||
probing
|
||||
.external_agent_options(FIXTURE_AGENT_ID, &probe_workspace)
|
||||
.await
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let pid = loop {
|
||||
if let Some(pid) = received(&workspace)
|
||||
.iter()
|
||||
.find(|entry| entry["method"] == "session/new")
|
||||
.and_then(|entry| entry["pid"].as_u64())
|
||||
{
|
||||
break pid;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
Instant::now() < deadline,
|
||||
"the probe never reached session/new"
|
||||
);
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
assert!(pid_alive(pid));
|
||||
manager.close_execution_admission().await;
|
||||
let ended = tokio::time::timeout(Duration::from_secs(20), probe).await??;
|
||||
assert!(ended.is_err());
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while pid_alive(pid) {
|
||||
anyhow::ensure!(
|
||||
Instant::now() < deadline,
|
||||
"the probe's agent outlived shutdown"
|
||||
);
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_chosen_model_reaches_the_agent_survives_reopen_and_usage_is_agent_reported() -> Result<()>
|
||||
{
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let offered = manager
|
||||
.external_agent_options(FIXTURE_AGENT_ID, &workspace)
|
||||
.await?;
|
||||
let chosen = &offered.controls[0].choices[1].value;
|
||||
assert_eq!(chosen, "model-b");
|
||||
|
||||
let thread = manager
|
||||
.create_thread(options_thread_request(&workspace, chosen))
|
||||
.await?;
|
||||
let turn = manager.start_turn(&thread.id, prompt("first")).await?;
|
||||
let settled = wait_for_turn(&manager, &turn.id).await?;
|
||||
assert_eq!(settled.status, RuntimeTurnStatus::Completed, "{settled:?}");
|
||||
let detail = manager.get_thread_detail(&thread.id).await?;
|
||||
let message = detail
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.kind == TurnItemKind::AgentMessage)
|
||||
.and_then(|item| item.detail.clone())
|
||||
.unwrap_or_default();
|
||||
assert!(message.contains("model=model-b"), "{message}");
|
||||
|
||||
let usage: Vec<_> = events(&manager, &thread.id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|event| event.event == "turn.agent_usage")
|
||||
.collect();
|
||||
let window = usage
|
||||
.iter()
|
||||
.find(|event| event.payload["kind"] == "context_window")
|
||||
.expect("a context window update");
|
||||
assert_eq!(window.payload["used"], 1200);
|
||||
assert_eq!(window.payload["size"], 200000);
|
||||
let total = usage
|
||||
.iter()
|
||||
.find(|event| event.payload["kind"] == "turn_total")
|
||||
.expect("a turn total");
|
||||
assert_eq!(total.payload["source"], "agent reported");
|
||||
assert_eq!(total.payload["cost"], "unknown");
|
||||
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
|
||||
assert_eq!(total.payload["tokens"]["input_tokens"], 100);
|
||||
assert_eq!(total.payload["context"]["used"], 1200);
|
||||
|
||||
assert!(manager.stop_external_agent(&thread.id).await?);
|
||||
let reopened = manager.start_turn(&thread.id, prompt("second")).await?;
|
||||
let settled = wait_for_turn(&manager, &reopened.id).await?;
|
||||
assert_eq!(settled.status, RuntimeTurnStatus::Completed, "{settled:?}");
|
||||
let log = received(&workspace);
|
||||
let sets: Vec<_> = log
|
||||
.iter()
|
||||
.filter(|entry| entry["method"] == "session/set_config_option")
|
||||
.collect();
|
||||
assert_eq!(sets.len(), 2, "{log:?}");
|
||||
assert!(sets.iter().all(|entry| entry["value"] == "model-b"));
|
||||
assert_ne!(
|
||||
sets[0]["pid"], sets[1]["pid"],
|
||||
"a new agent process took the saved choice"
|
||||
);
|
||||
let prompts: Vec<_> = log
|
||||
.iter()
|
||||
.filter(|entry| entry["method"] == "session/prompt")
|
||||
.collect();
|
||||
assert_eq!(prompts.len(), 2);
|
||||
assert!(log.iter().any(|entry| entry["method"] == "session/load"));
|
||||
assert_eq!(prompts[1]["state"]["model"], "model-b");
|
||||
assert_eq!(
|
||||
manager
|
||||
.store
|
||||
.load_thread(&thread.id)?
|
||||
.executor
|
||||
.and_then(|record| record.model)
|
||||
.as_deref(),
|
||||
Some("model-b")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn an_agent_that_reports_no_usage_says_unknown() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::fs::write(
|
||||
workspace.join("options-fixture.json"),
|
||||
r#"{"no_usage":true}"#,
|
||||
)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let thread = manager
|
||||
.create_thread(fixture_thread_request(&workspace, "ask"))
|
||||
.await?;
|
||||
let turn = manager.start_turn(&thread.id, prompt("quiet")).await?;
|
||||
wait_for_turn(&manager, &turn.id).await?;
|
||||
let events = events(&manager, &thread.id).await?;
|
||||
let usage: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event.event == "turn.agent_usage")
|
||||
.collect();
|
||||
assert_eq!(usage.len(), 1, "{usage:?}");
|
||||
assert_eq!(usage[0].payload["tokens"], "unknown");
|
||||
assert_eq!(usage[0].payload["context"], "unknown");
|
||||
assert_eq!(usage[0].payload["source"], "none");
|
||||
assert_eq!(usage[0].payload["cost"], "unknown");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_stale_or_withdrawn_choice_is_refused_before_any_prompt() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
|
||||
let invented = manager
|
||||
.create_thread(options_thread_request(&workspace, "model-z"))
|
||||
.await?;
|
||||
let error = manager
|
||||
.start_turn(&invented.id, prompt("never"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
refusal_code(&error),
|
||||
Some("choice_not_offered"),
|
||||
"{error:#}"
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
workspace.join("options-fixture.json"),
|
||||
r#"{"withdraw":["model-b"]}"#,
|
||||
)?;
|
||||
let withdrawn = manager
|
||||
.create_thread(options_thread_request(&workspace, "model-b"))
|
||||
.await?;
|
||||
let error = manager
|
||||
.start_turn(&withdrawn.id, prompt("never"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
refusal_code(&error),
|
||||
Some("choice_not_offered"),
|
||||
"{error:#}"
|
||||
);
|
||||
|
||||
let log = received(&workspace);
|
||||
assert!(log.iter().all(|entry| entry["method"] != "session/prompt"));
|
||||
assert!(
|
||||
log.iter()
|
||||
.all(|entry| entry["method"] != "session/set_config_option")
|
||||
);
|
||||
for pid in log.iter().filter_map(|entry| entry["pid"].as_u64()) {
|
||||
assert!(!pid_alive(pid), "a refused setup left agent {pid} running");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn per_turn_controls_an_agent_cannot_honor_are_typed_refusals() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, options_fixture());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let thread = manager
|
||||
.create_thread(fixture_thread_request(&workspace, "ask"))
|
||||
.await?;
|
||||
for request in [
|
||||
StartTurnRequest {
|
||||
allowed_tools: Some(Vec::new()),
|
||||
..prompt("x")
|
||||
},
|
||||
StartTurnRequest {
|
||||
model: Some("model-b".to_string()),
|
||||
..prompt("x")
|
||||
},
|
||||
StartTurnRequest {
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
..prompt("x")
|
||||
},
|
||||
] {
|
||||
let error = manager.start_turn(&thread.id, request).await.unwrap_err();
|
||||
assert_eq!(
|
||||
refusal_code(&error),
|
||||
Some("unsupported_control"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
received(&workspace).is_empty(),
|
||||
"no agent was started for a refused turn"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fixture_lines(workspace: &Path, name: &str) -> Vec<Value> {
|
||||
std::fs::read_to_string(workspace.join(name))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str(line).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupting_a_pending_approval_answers_the_agents_request_as_cancelled() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, fixture_script());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let thread = manager
|
||||
.create_thread(fixture_thread_request(&workspace, "ask"))
|
||||
.await?;
|
||||
let turn = manager.start_turn(&thread.id, prompt("stop me")).await?;
|
||||
wait_for_approval(&manager, &thread.id).await?;
|
||||
manager.interrupt_turn(&thread.id, &turn.id).await?;
|
||||
assert_eq!(
|
||||
wait_for_turn(&manager, &turn.id).await?.status,
|
||||
RuntimeTurnStatus::Interrupted
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let outcomes = loop {
|
||||
let outcomes = fixture_lines(&workspace, "permission-outcomes.jsonl");
|
||||
if !outcomes.is_empty() || Instant::now() >= deadline {
|
||||
break outcomes;
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![json!({ "outcome": "cancelled" })],
|
||||
"the agent never received a cancelled answer for its open permission request"
|
||||
);
|
||||
assert!(!workspace.join("fixture.txt").exists());
|
||||
manager.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn additional_directories_reach_only_an_agent_that_advertises_them() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, fixture_script());
|
||||
let root = tempfile::tempdir()?;
|
||||
let extra = root.path().join("extra");
|
||||
std::fs::create_dir_all(&extra)?;
|
||||
let extra = extra.canonicalize()?;
|
||||
let mut sent = Vec::new();
|
||||
for (name, advertises) in [("quiet", false), ("capable", true)] {
|
||||
let workspace = root.path().join(name);
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::fs::write(
|
||||
workspace.join("fixture-plan.json"),
|
||||
json!({ "additionalDirectories": advertises }).to_string(),
|
||||
)?;
|
||||
let manager = test_manager(root.path().join(format!("runtime-{name}")))?;
|
||||
let mut request = fixture_thread_request(&workspace, "ask");
|
||||
request.add_dirs = vec![extra.clone()];
|
||||
let thread = manager.create_thread(request).await?;
|
||||
let turn = manager.start_turn(&thread.id, prompt("hello")).await?;
|
||||
let approval = wait_for_approval(&manager, &thread.id).await?;
|
||||
manager
|
||||
.deliver_external_approval(
|
||||
&approval.id,
|
||||
ExternalApprovalDecision::Deny { remember: false },
|
||||
)
|
||||
.await?;
|
||||
wait_for_turn(&manager, &turn.id).await?;
|
||||
sent.push(fixture_lines(&workspace, "session-params.jsonl"));
|
||||
manager.shutdown_and_wait().await?;
|
||||
}
|
||||
assert_eq!(sent[0].len(), 1);
|
||||
assert!(
|
||||
sent[0][0]["params"].get("additionalDirectories").is_none(),
|
||||
"an agent that did not advertise the capability was sent directories: {:?}",
|
||||
sent[0]
|
||||
);
|
||||
assert_eq!(
|
||||
sent[1][0]["params"]["additionalDirectories"],
|
||||
json!([extra.display().to_string()])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_mode_and_command_updates_are_projected() -> Result<()> {
|
||||
if !node_available().await {
|
||||
return Ok(());
|
||||
}
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, fixture_script());
|
||||
let root = tempfile::tempdir()?;
|
||||
let workspace = root.path().join("work");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::fs::write(
|
||||
workspace.join("fixture-plan.json"),
|
||||
json!({ "extraUpdates": true }).to_string(),
|
||||
)?;
|
||||
let manager = test_manager(root.path().join("runtime"))?;
|
||||
let thread = manager
|
||||
.create_thread(fixture_thread_request(&workspace, "ask"))
|
||||
.await?;
|
||||
let turn = manager.start_turn(&thread.id, prompt("hello")).await?;
|
||||
let approval = wait_for_approval(&manager, &thread.id).await?;
|
||||
manager
|
||||
.deliver_external_approval(
|
||||
&approval.id,
|
||||
ExternalApprovalDecision::Deny { remember: false },
|
||||
)
|
||||
.await?;
|
||||
wait_for_turn(&manager, &turn.id).await?;
|
||||
let events = events(&manager, &thread.id).await?;
|
||||
let mode = events
|
||||
.iter()
|
||||
.find(|event| event.event == "turn.agent_mode")
|
||||
.expect("a mode update is projected");
|
||||
assert_eq!(mode.payload["mode"], "review");
|
||||
let commands = events
|
||||
.iter()
|
||||
.find(|event| event.event == "turn.agent_commands")
|
||||
.expect("a command update is projected");
|
||||
assert_eq!(commands.payload["commands"][0]["name"], "compact");
|
||||
let detail = manager.get_thread_detail(&thread.id).await?;
|
||||
assert!(
|
||||
detail
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.kind == TurnItemKind::Status
|
||||
&& item
|
||||
.detail
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("[in_progress] Write the fixture")),
|
||||
"plan_update never reached a plan item"
|
||||
);
|
||||
manager.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1909,6 +1909,7 @@ pub struct SubAgentSpawnOptions {
|
||||
pub preserve_runtime_profile: Option<WorkerRuntimeProfile>,
|
||||
/// Workflow run that owns this child; recorded on the worker spec.
|
||||
pub workflow_run_id: Option<String>,
|
||||
pub reserved_agent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -10159,13 +10160,23 @@ impl SubAgentManager {
|
||||
runtime.model = model.to_string();
|
||||
}
|
||||
let effective_model = runtime.model.clone();
|
||||
let agent_id = loop {
|
||||
let candidate = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
if !self.agents.contains_key(&candidate)
|
||||
&& !self.worker_records.contains_key(&candidate)
|
||||
{
|
||||
break candidate;
|
||||
let agent_id = match options.reserved_agent_id.clone() {
|
||||
Some(reserved) => {
|
||||
if self.agents.contains_key(&reserved)
|
||||
|| self.worker_records.contains_key(&reserved)
|
||||
{
|
||||
return Err(anyhow!("Reserved agent id {reserved} is already in use"));
|
||||
}
|
||||
reserved
|
||||
}
|
||||
None => loop {
|
||||
let candidate = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
if !self.agents.contains_key(&candidate)
|
||||
&& !self.worker_records.contains_key(&candidate)
|
||||
{
|
||||
break candidate;
|
||||
}
|
||||
},
|
||||
};
|
||||
let identity = ChildActivationIdentity {
|
||||
owner_session_id: runtime.context.state_namespace.clone(),
|
||||
@@ -10342,6 +10353,7 @@ impl SubAgentManager {
|
||||
None
|
||||
};
|
||||
let durable_registration = public_child_id.is_some()
|
||||
|| options.reserved_agent_id.is_some()
|
||||
|| write_capable
|
||||
|| runtime_origin_turn_id.is_some()
|
||||
|| continuation_from.is_some()
|
||||
@@ -15893,8 +15905,10 @@ async fn spawn_subagent_from_input(
|
||||
verified_posture_denials: bool,
|
||||
workflow_identity: Option<&WorkflowTaskSpawnIdentity>,
|
||||
response_schema: Option<Value>,
|
||||
member_roster: Option<&crate::fleet::roster::FleetRoster>,
|
||||
member: Option<(&crate::fleet::roster::FleetRoster, Option<&str>)>,
|
||||
) -> Result<(SubAgentResult, WorkflowTaskSpawnMetadata), ToolError> {
|
||||
let member_roster = member.map(|(roster, _)| roster);
|
||||
let reserved_agent_id = member.and_then(|(_, agent_id)| agent_id.map(str::to_string));
|
||||
let exact_fleet_binding =
|
||||
workflow_identity.and_then(|identity| identity.exact_fleet_binding.as_ref());
|
||||
if start_requests_read_only_role(&input) {
|
||||
@@ -16288,6 +16302,7 @@ async fn spawn_subagent_from_input(
|
||||
claim_pre_namespaced: false,
|
||||
preserve_runtime_profile: None,
|
||||
workflow_run_id: workflow_identity.map(|identity| identity.workflow_run_id.clone()),
|
||||
reserved_agent_id,
|
||||
};
|
||||
let spawn_owner = manager.clone();
|
||||
let result = codewhale_runtime::runtime_control::owner_work(move || {
|
||||
@@ -23673,6 +23688,7 @@ pub struct FleetMemberStart {
|
||||
pub member_id: String,
|
||||
pub display_name: String,
|
||||
pub prompt: String,
|
||||
pub reserved_agent_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn start_fleet_member(
|
||||
@@ -23728,7 +23744,7 @@ pub async fn start_fleet_member(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some(&roster),
|
||||
Some((&roster, start.reserved_agent_id.as_deref())),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#[path = "support/acp_conformance.rs"]
|
||||
mod conformance;
|
||||
|
||||
use codewhale_engine::fleet::acp::{AcpClient, PermissionPolicy};
|
||||
use codewhale_engine::fleet::host::{
|
||||
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
|
||||
LocalProcessFleetHostAdapter,
|
||||
};
|
||||
use conformance::{Allowed, Conformance, Side};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const KNOWN: &[Allowed] = &[
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "initialize response",
|
||||
problem: "field `.agentCapabilities.sessionCapabilities.fork` is not in the official type",
|
||||
reason: "options fixture advertises session/fork, which is unstable_session_fork, not stable v1",
|
||||
},
|
||||
Allowed {
|
||||
side: Side::Agent,
|
||||
frame: "session/prompt response",
|
||||
problem: "field `.usage` is not in the official type",
|
||||
reason: "options fixture reports PromptResponse.usage, which is unstable_end_turn_token_usage, not stable v1",
|
||||
},
|
||||
];
|
||||
|
||||
const STEP: Duration = Duration::from_secs(20);
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn captured(
|
||||
agent: &str,
|
||||
workspace: &Path,
|
||||
log: &Path,
|
||||
drive: impl FnOnce(&mut AcpClient),
|
||||
) -> Vec<(Side, serde_json::Value)> {
|
||||
let mut host = LocalProcessFleetHostAdapter::new(workspace);
|
||||
let mut request = FleetWorkerStartRequest::new(
|
||||
"w-acp-conformance",
|
||||
FleetWorkerCommand::new(
|
||||
"node",
|
||||
[
|
||||
fixture("acp-tee.mjs"),
|
||||
log.display().to_string(),
|
||||
fixture(agent),
|
||||
],
|
||||
),
|
||||
);
|
||||
request.transport = FleetWorkerTransport::JsonRpc;
|
||||
request.cwd = Some(workspace.to_path_buf());
|
||||
host.start_worker(request).unwrap();
|
||||
let mut client = AcpClient::new(host.take_worker_io("w-acp-conformance").unwrap());
|
||||
drive(&mut client);
|
||||
host.stop_worker("w-acp-conformance").unwrap();
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let (tag, frame) = line.split_once(' ').unwrap();
|
||||
let side = if tag == "C" {
|
||||
Side::Client
|
||||
} else {
|
||||
Side::Agent
|
||||
};
|
||||
(side, serde_json::from_str(frame).unwrap())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_acp_client_and_its_fixture_agents_speak_official_v1_shapes() {
|
||||
let has_node = std::process::Command::new("node")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success());
|
||||
if !has_node {
|
||||
eprintln!("skipped: node is not installed");
|
||||
return;
|
||||
}
|
||||
let capture = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut frames = captured(
|
||||
"acp-agent.mjs",
|
||||
workspace.path(),
|
||||
&capture.path().join("agent.log"),
|
||||
|client| {
|
||||
let identity = client.initialize(STEP).unwrap();
|
||||
let session = client
|
||||
.new_session(&identity, workspace.path(), &[], STEP)
|
||||
.unwrap()
|
||||
.session_id;
|
||||
client
|
||||
.set_config_option(&session, "model", "fixture-model", STEP)
|
||||
.unwrap();
|
||||
for policy in [
|
||||
PermissionPolicy::AllowOnce,
|
||||
PermissionPolicy::RejectOnce,
|
||||
PermissionPolicy::Cancel,
|
||||
] {
|
||||
client.prompt(&session, "conform", policy, STEP).unwrap();
|
||||
}
|
||||
client.cancel(&session).unwrap();
|
||||
client
|
||||
.reopen_session(&identity, &session, workspace.path(), &[], STEP)
|
||||
.unwrap();
|
||||
},
|
||||
);
|
||||
let options_workspace = tempfile::tempdir().unwrap();
|
||||
frames.extend(captured(
|
||||
"acp-options-agent.mjs",
|
||||
options_workspace.path(),
|
||||
&capture.path().join("options.log"),
|
||||
|client| {
|
||||
let identity = client.initialize(STEP).unwrap();
|
||||
let created = client
|
||||
.new_session(&identity, options_workspace.path(), &[], STEP)
|
||||
.unwrap();
|
||||
let mut applied = 0;
|
||||
for control in &created.offer.controls {
|
||||
if control.write.is_none() || control.unavailable.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(choice) = control.choices.last() {
|
||||
client
|
||||
.apply_control(&created.session_id, control, &choice.value, STEP)
|
||||
.unwrap();
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
applied >= 2,
|
||||
"options fixture offered {applied} writable controls"
|
||||
);
|
||||
client
|
||||
.prompt(
|
||||
&created.session_id,
|
||||
"conform",
|
||||
PermissionPolicy::AllowOnce,
|
||||
STEP,
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
));
|
||||
|
||||
let mut observed = Conformance::checking(&[Side::Client, Side::Agent]);
|
||||
for (side, frame) in &frames {
|
||||
observed.observe(*side, frame);
|
||||
}
|
||||
assert!(
|
||||
observed.frames() > 30,
|
||||
"only {} frames observed",
|
||||
observed.frames()
|
||||
);
|
||||
observed.assert_only(KNOWN);
|
||||
}
|
||||
+16
-2
@@ -1,6 +1,6 @@
|
||||
// A deterministic Agent Client Protocol agent for the fleet transport test.
|
||||
// Speaks newline-framed JSON-RPC on stdio. No dependencies, no network.
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HANG = process.env.HANG === '1';
|
||||
@@ -15,6 +15,12 @@ let pendingPrompt = null;
|
||||
let calls = 0;
|
||||
let configModel = null;
|
||||
|
||||
const plan = () => {
|
||||
const path = join(process.cwd(), 'fixture-plan.json');
|
||||
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
||||
};
|
||||
const record = (name, value) => appendFileSync(join(process.cwd(), name), `${JSON.stringify(value)}\n`);
|
||||
|
||||
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });
|
||||
const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
|
||||
@@ -41,6 +47,11 @@ function askPermission(toolCall) {
|
||||
async function prompt(request) {
|
||||
const text = (request.params?.prompt ?? []).map((block) => block.text ?? '').join('');
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking about it.' } } });
|
||||
if (plan().extraUpdates) {
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'plan_update', entries: [{ content: 'Write the fixture', status: 'in_progress' }] } });
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: 'review' } });
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'available_commands_update', availableCommands: [{ name: 'compact', description: 'Shrink the context' }] } });
|
||||
}
|
||||
for (const chunk of [`You said: `, text, `.`]) {
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk } } });
|
||||
}
|
||||
@@ -67,6 +78,7 @@ function handle(message) {
|
||||
if (message.id === 9001 && permissionWaiter) {
|
||||
const resolve = permissionWaiter;
|
||||
permissionWaiter = null;
|
||||
record('permission-outcomes.jsonl', message.result?.outcome ?? null);
|
||||
resolve(message.result);
|
||||
}
|
||||
return;
|
||||
@@ -77,15 +89,17 @@ function handle(message) {
|
||||
protocolVersion: 1,
|
||||
agentInfo: { name: 'fixture-agent', version: '1.0.0' },
|
||||
authMethods: [],
|
||||
agentCapabilities: { loadSession: true },
|
||||
agentCapabilities: { loadSession: true, sessionCapabilities: plan().additionalDirectories ? { additionalDirectories: {} } : {} },
|
||||
});
|
||||
break;
|
||||
case 'session/new':
|
||||
record('session-params.jsonl', { method: message.method, params: message.params });
|
||||
sessionId = `fixture-session-${process.pid}`;
|
||||
cwd = message.params?.cwd ?? cwd;
|
||||
result(message.id, { sessionId });
|
||||
break;
|
||||
case 'session/load':
|
||||
record('session-params.jsonl', { method: message.method, params: message.params });
|
||||
sessionId = message.params?.sessionId ?? null;
|
||||
cwd = message.params?.cwd ?? cwd;
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'replayed history' } } });
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
// A deterministic Agent Client Protocol agent that advertises its own models,
|
||||
// a mode, a config option and one control Codewhale cannot write, and reports
|
||||
// token usage. Behavior is steered by `options-fixture.json` in the session
|
||||
// cwd; everything it receives is appended to `options-received.jsonl` there.
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const plan = () => {
|
||||
const path = join(process.cwd(), 'options-fixture.json');
|
||||
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
||||
};
|
||||
const record = (entry) =>
|
||||
appendFileSync(join(process.cwd(), 'options-received.jsonl'), `${JSON.stringify({ pid: process.pid, ...entry })}\n`);
|
||||
writeFileSync(join(process.cwd(), 'options-env.json'), JSON.stringify(process.env));
|
||||
|
||||
const MODELS = [
|
||||
{ value: 'model-a', name: 'Model A', description: 'Fast' },
|
||||
{ value: 'model-b', name: 'Model B', description: 'Careful' },
|
||||
{ value: 'model-c', name: 'Model C' },
|
||||
];
|
||||
const EFFORTS = [
|
||||
{ value: 'low', name: 'Low' },
|
||||
{ value: 'high', name: 'High' },
|
||||
];
|
||||
const MODES = [
|
||||
{ id: 'default', name: 'Default' },
|
||||
{ id: 'review', name: 'Review only', description: 'Reads, never edits' },
|
||||
];
|
||||
|
||||
let buffer = '';
|
||||
let sessionId = null;
|
||||
const state = { model: 'model-a', effort: 'low', mode: 'default' };
|
||||
|
||||
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });
|
||||
const failure = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
|
||||
|
||||
function offer() {
|
||||
const withdrawn = new Set(plan().withdraw ?? []);
|
||||
const models = MODELS.filter((model) => !withdrawn.has(model.value));
|
||||
const configOptions = [
|
||||
{ id: 'model', name: 'Model', category: 'model', type: 'select', currentValue: state.model, options: models },
|
||||
{ id: 'effort', name: 'Reasoning effort', category: 'thought_level', type: 'select', currentValue: state.effort, options: EFFORTS },
|
||||
{ id: 'verbose', name: 'Verbose logging', type: 'boolean', currentValue: false },
|
||||
];
|
||||
const reply = {
|
||||
configOptions,
|
||||
modes: { currentModeId: state.mode, availableModes: MODES },
|
||||
};
|
||||
if (plan().malformed) {
|
||||
reply.configOptions.push({ id: '', name: 'No id', type: 'select', currentValue: 'x', options: [] });
|
||||
reply.configOptions.push({ id: 'empty', name: 'Empty', type: 'select', currentValue: 'x', options: [] });
|
||||
}
|
||||
if (plan().huge) {
|
||||
reply.configOptions.push({
|
||||
id: 'many',
|
||||
name: 'Many',
|
||||
type: 'select',
|
||||
currentValue: 'v0',
|
||||
options: Array.from({ length: 600 }, (_, index) => ({ value: `v${index}`, name: `Value ${index}` })),
|
||||
});
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
function offered(config, value) {
|
||||
const found = offer().configOptions.find((option) => option.id === config);
|
||||
return Boolean(found && found.type === 'select' && found.options.some((option) => option.value === value));
|
||||
}
|
||||
|
||||
function prompt(request) {
|
||||
const text = (request.params?.prompt ?? []).map((block) => block.text ?? '').join('');
|
||||
record({ method: 'session/prompt', text, state: { ...state } });
|
||||
notify('session/update', {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: `model=${state.model} effort=${state.effort} mode=${state.mode}` },
|
||||
},
|
||||
});
|
||||
if (!plan().no_usage) {
|
||||
notify('session/update', { sessionId, update: { sessionUpdate: 'usage_update', used: 1200, size: 200000 } });
|
||||
}
|
||||
result(request.id, {
|
||||
stopReason: 'end_turn',
|
||||
...(plan().no_usage ? {} : { usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150, thoughtTokens: 7 } }),
|
||||
});
|
||||
}
|
||||
|
||||
function handle(message) {
|
||||
if (message.id !== undefined && message.method === undefined) return;
|
||||
const params = message.params ?? {};
|
||||
switch (message.method) {
|
||||
case 'initialize':
|
||||
result(message.id, {
|
||||
protocolVersion: 1,
|
||||
agentInfo: { name: 'options-fixture', version: '1.0.0' },
|
||||
authMethods: [],
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: true, embeddedContext: true },
|
||||
sessionCapabilities: { fork: {} },
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'session/new':
|
||||
if (plan().auth_required) {
|
||||
failure(message.id, -32000, 'Authentication required');
|
||||
break;
|
||||
}
|
||||
sessionId = `options-session-${process.pid}`;
|
||||
record({ method: 'session/new', cwd: params.cwd });
|
||||
if (plan().hang) break;
|
||||
result(message.id, { sessionId, ...offer() });
|
||||
break;
|
||||
case 'session/load':
|
||||
case 'session/resume':
|
||||
sessionId = params.sessionId ?? null;
|
||||
record({ method: message.method, sessionId });
|
||||
result(message.id, offer());
|
||||
break;
|
||||
case 'session/set_config_option':
|
||||
record({ method: message.method, configId: params.configId, value: params.value });
|
||||
if (!offered(params.configId, params.value)) {
|
||||
failure(message.id, -32602, 'value not offered');
|
||||
break;
|
||||
}
|
||||
state[params.configId] = params.value;
|
||||
result(message.id, { configOptions: offer().configOptions });
|
||||
break;
|
||||
case 'session/set_mode':
|
||||
record({ method: message.method, modeId: params.modeId });
|
||||
if (!MODES.some((mode) => mode.id === params.modeId)) {
|
||||
failure(message.id, -32602, 'mode not offered');
|
||||
break;
|
||||
}
|
||||
state.mode = params.modeId;
|
||||
result(message.id, {});
|
||||
break;
|
||||
case 'session/prompt':
|
||||
prompt(message);
|
||||
break;
|
||||
case 'session/cancel':
|
||||
break;
|
||||
default:
|
||||
if (message.id !== undefined) failure(message.id, -32601, `no method ${message.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
let index;
|
||||
while ((index = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 1);
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
handle(JSON.parse(line));
|
||||
} catch (error) {
|
||||
process.stderr.write(`options fixture: bad line: ${String(error)}\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => process.exit(0));
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
const [logPath, agentScript] = process.argv.slice(2);
|
||||
const child = spawn(process.execPath, [agentScript], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
|
||||
|
||||
function tap(tag) {
|
||||
let buffer = '';
|
||||
return (chunk) => {
|
||||
buffer += chunk;
|
||||
let index;
|
||||
while ((index = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 1);
|
||||
if (line.trim()) appendFileSync(logPath, `${tag} ${line}\n`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fromClient = tap('C');
|
||||
const fromAgent = tap('A');
|
||||
process.stdin.setEncoding('utf8');
|
||||
child.stdout.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
fromClient(chunk);
|
||||
child.stdin.write(chunk);
|
||||
});
|
||||
process.stdin.on('end', () => child.stdin.end());
|
||||
child.stdout.on('data', (chunk) => {
|
||||
fromAgent(chunk);
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
child.on('exit', (code) => process.exit(code ?? 0));
|
||||
@@ -0,0 +1,657 @@
|
||||
{
|
||||
"schema": "codewhale.dsh-acceptance-pins/1",
|
||||
"issue": "CW-7051",
|
||||
"source": "DSH-PINNED-ACCEPTANCE.json (DSH-00) re-hashed against the survey tarballs",
|
||||
"membership": "THE-33 option A, ratified in CURRENT_DECISIONS section 125 item 1 (DSH-H01)",
|
||||
"target_dsh_version": "0.2.2-rc.1",
|
||||
"supported_os": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"row": 1,
|
||||
"package": "@michengai/dsh-code-review",
|
||||
"version": "1.0.9",
|
||||
"family": "headless tool/command",
|
||||
"tarball": "michengai-dsh-code-review-1.0.9.tgz",
|
||||
"tarball_sha256": "09215a2f809327db33074a44d4371439b8163d953f1826b3f2a9481828b6bee3",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 2,
|
||||
"package": "@michengai/dsh-btw",
|
||||
"version": "1.3.7",
|
||||
"family": "headless command",
|
||||
"tarball": "michengai-dsh-btw-1.3.7.tgz",
|
||||
"tarball_sha256": "7cb6a910e82b21362a57e6007ad20df01d50048b6ef789ca6c5ed57be59622dc",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.text-interaction",
|
||||
"conversation.input.dock",
|
||||
"conversation.user.text.node",
|
||||
"plugins.bundle.config",
|
||||
"plugins.row.config"
|
||||
],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 3,
|
||||
"package": "@linxin666/dsh-liangshen",
|
||||
"version": "0.4.16",
|
||||
"family": "prompt/preset",
|
||||
"tarball": "linxin666-dsh-liangshen-0.4.16.tgz",
|
||||
"tarball_sha256": "ddbc39bbf88c0bbb724a7b9e129afd55a53f58d6ae68fe9777287c13a9de7b3e",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.input.right",
|
||||
"plugins.bundle.config",
|
||||
"settings.plugin.item"
|
||||
],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 4,
|
||||
"package": "dsh-anchored-standard",
|
||||
"version": "1.2.1",
|
||||
"family": "prompt/preset",
|
||||
"tarball": "Jungod1121-dsh-anchored-standard.tar.gz",
|
||||
"tarball_sha256": "3ff95dbb5e7f8bcba984afdf503f06abe0896b298afb39d533a1eafef2d7bfa2",
|
||||
"admission": "admit",
|
||||
"admission_basis": "no-dsh-declaration",
|
||||
"declares_client": false,
|
||||
"declared_dsh_slot_keys": [],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 5,
|
||||
"package": "@michengai/dsh-agency-agents",
|
||||
"version": "1.2.27",
|
||||
"family": "prompt/skills + agents",
|
||||
"tarball": "michengai-dsh-agency-agents-1.2.27.tgz",
|
||||
"tarball_sha256": "fafb0c8a645ea088cbf5b427e135a62bf8c506c0fb5be9fc65bce4f635fd0216",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.input.dock",
|
||||
"plugins.bundle.config",
|
||||
"settings.section",
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 6,
|
||||
"package": "@liustack/modsearch",
|
||||
"version": "5.10.12",
|
||||
"family": "headless tool",
|
||||
"tarball": "liustack-modsearch-5.10.12.tgz",
|
||||
"tarball_sha256": "c6ebdba4ce896b76bc97351919ec717d71fd28d4a36aa33529016a9dd79c4e91",
|
||||
"admission": "admit",
|
||||
"admission_basis": "no-dsh-declaration",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"settings.plugin.item"
|
||||
],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 7,
|
||||
"package": "@liustack/modlens",
|
||||
"version": "3.27.3",
|
||||
"family": "headless tool (vision)",
|
||||
"tarball": "liustack-modlens-3.27.3.tgz",
|
||||
"tarball_sha256": "1cbf6fb0ec1221b8a8af27458128d6972b4083bf570aa263168f5ac1c580c325",
|
||||
"admission": "admit",
|
||||
"admission_basis": "no-dsh-declaration",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"settings.plugin.item"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 8,
|
||||
"package": "@anionex/dsh-vision-toolkit",
|
||||
"version": "0.1.49",
|
||||
"family": "headless tool",
|
||||
"tarball": "anionex-dsh-vision-toolkit-0.1.49.tgz",
|
||||
"tarball_sha256": "09ffed8a5eb3dddbb61aee896117bf37fd3087837f2a1df2a6ed86a353f8d97d",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.input.dock",
|
||||
"settings.section",
|
||||
"tool.call.toolview"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"credentials",
|
||||
"subprocess"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 9,
|
||||
"package": "dsh-image-gen",
|
||||
"version": "0.11.1",
|
||||
"family": "headless tool + panel",
|
||||
"tarball": "dsh-image-gen-0.11.1.tgz",
|
||||
"tarball_sha256": "4d8d8add5614d8b14c031b0e9ba04de042a258f53913e23eaa297f6d0a4d0619",
|
||||
"admission": "admit",
|
||||
"admission_basis": "no-dsh-declaration",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.node",
|
||||
"conversation.input.right",
|
||||
"conversation.view",
|
||||
"main.page-menu",
|
||||
"plugins.bundle.config",
|
||||
"settings.plugin.item",
|
||||
"sidebar.right.pane.tab",
|
||||
"tool.call.toolview"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"credentials"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 10,
|
||||
"package": "@dfy-plugins/dsh-image-generation",
|
||||
"version": "0.1.4",
|
||||
"family": "headless tool",
|
||||
"tarball": "dfy-plugins-dsh-image-generation-0.1.4.tgz",
|
||||
"tarball_sha256": "fbd5e5b2bd874914460b9d367dd517936eeee804ce82ddc5d05f3911ab9040ea",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"plugins.row.config",
|
||||
"tool.call.toolview"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"credentials",
|
||||
"fs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 11,
|
||||
"package": "dsh-univer-office",
|
||||
"version": "0.4.0",
|
||||
"family": "headless tool + panel",
|
||||
"tarball": "dsh-univer-office-0.4.0.tgz",
|
||||
"tarball_sha256": "c5614b3e2dbaa809d6376a19821a108ddf8ae0e79818d4afd77be79de7cac2b8",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.turnTail",
|
||||
"conversation.input.dock",
|
||||
"plugins.bundle.config",
|
||||
"settings.plugin.item",
|
||||
"sidebar.right.pane.tab"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"subprocess"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 12,
|
||||
"package": "@michengai/dsh-skills-manager",
|
||||
"version": "1.4.0",
|
||||
"family": "skills + panel",
|
||||
"tarball": "michengai-dsh-skills-manager-1.4.0.tgz",
|
||||
"tarball_sha256": "81f2919071ad554a87e627e068bab0d222afe9f757dd5b9b2e1caf6591544f3c",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"settings.section",
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"fs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 13,
|
||||
"package": "@nanmicoder/dsh-agent-teams",
|
||||
"version": "0.1.29",
|
||||
"family": "agent-teams",
|
||||
"tarball": "nanmicoder-dsh-agent-teams-0.1.29.tgz",
|
||||
"tarball_sha256": "25aba307cb422aa20dbcaf2d25317425bd6f82ab2b1b400a1eda2d659cf57367",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.node",
|
||||
"conversation.chat.turnTail",
|
||||
"conversation.session.header.actions",
|
||||
"sidebar.right.pane.tab"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm",
|
||||
"drive-grant"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 14,
|
||||
"package": "dsh-agent-squad",
|
||||
"version": "0.1.5",
|
||||
"family": "agent-teams",
|
||||
"tarball": "dsh-agent-squad-0.1.5.tgz",
|
||||
"tarball_sha256": "126c632c68d25ce39f30d477f15f4badf76198688dadfc8f90b9fdb635918c70",
|
||||
"admission": "refuse",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.node"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm"
|
||||
],
|
||||
"refusal": {
|
||||
"code": "incompatible-version",
|
||||
"field": "peerDependencies.@deepseek-ai/dsh-agent",
|
||||
"range": "^0.1.0-rc.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"row": 15,
|
||||
"package": "@michengai/dsh-automation",
|
||||
"version": "1.0.22",
|
||||
"family": "agent-teams / out-of-turn",
|
||||
"tarball": "michengai-dsh-automation-1.0.22.tgz",
|
||||
"tarball_sha256": "1b3ac21c82b2e68dd2153444335f1b70c4be02c280ebc2778820c2a3cf832cc3",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"settings.section",
|
||||
"sidebar.panellist",
|
||||
"sidebar.session.row.leading",
|
||||
"sidebar.workspaces"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm",
|
||||
"out-of-turn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 16,
|
||||
"package": "dsh-mnemon",
|
||||
"version": "0.5.43",
|
||||
"family": "memory / out-of-turn",
|
||||
"tarball": "dsh-mnemon-0.5.43.tgz",
|
||||
"tarball_sha256": "5c1e6a6c4f3c14fda1c25343de4102dfd2f04f923f2f17551cbf9c8c112e0104",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.assistant-actions",
|
||||
"conversation.chat.turnTail",
|
||||
"conversation.view",
|
||||
"plugins.bundle.config",
|
||||
"plugins.row.config",
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"egress"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 17,
|
||||
"package": "@linxin666/dsh-client-ui-task-board",
|
||||
"version": "0.4.16",
|
||||
"family": "agent-teams panel",
|
||||
"tarball": "linxin666-dsh-client-ui-task-board-0.4.16.tgz",
|
||||
"tarball_sha256": "aee4785ea00613baee05f3ff21a8a2ace50d31c7b856d8140328f69dfc066456",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"settings.plugin.item",
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 18,
|
||||
"package": "@michengai/dsh-im-connect",
|
||||
"version": "1.0.34",
|
||||
"family": "IM bridge",
|
||||
"tarball": "michengai-dsh-im-connect-1.0.34.tgz",
|
||||
"tarball_sha256": "f4c2e114f1926fecd6f981081d69800420c1dcb21b2eb020259a932378583d26",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"settings.section",
|
||||
"sidebar.panellist",
|
||||
"sidebar.session.row.leading",
|
||||
"sidebar.workspaces"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm",
|
||||
"credentials",
|
||||
"egress"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 19,
|
||||
"package": "dsh-im",
|
||||
"version": "1.0.5",
|
||||
"family": "IM bridge",
|
||||
"tarball": "dsh-im-1.0.5.tgz",
|
||||
"tarball_sha256": "23875de57a006a5442cc1585071fc7c0e4d5b1dbd782230a31aa8f5c758f9380",
|
||||
"admission": "refuse",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": false,
|
||||
"declared_dsh_slot_keys": [],
|
||||
"named_brokers_needed": [
|
||||
"approval"
|
||||
],
|
||||
"refusal": {
|
||||
"code": "incompatible-version",
|
||||
"field": "peerDependencies.@deepseek-ai/dsh-agent",
|
||||
"range": "^0.1.0-rc.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"row": 20,
|
||||
"package": "dsh-context",
|
||||
"version": "0.83.0",
|
||||
"family": "sidebar/panel",
|
||||
"tarball": "dsh-context-0.83.0.tgz",
|
||||
"tarball_sha256": "fc9203b1a85081cf73808ca87e226219d111aebef7e2ce3dfc6a090649b421cc",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.assistant-actions",
|
||||
"conversation.input.overlay",
|
||||
"conversation.view",
|
||||
"plugins.bundle.config",
|
||||
"sidebar.panellist",
|
||||
"sidebar.right.pane.tab"
|
||||
],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 21,
|
||||
"package": "dsh-cost-meter",
|
||||
"version": "1.9.62",
|
||||
"family": "panel + account",
|
||||
"tarball": "dsh-cost-meter-1.9.62.tgz",
|
||||
"tarball_sha256": "7196cc36d95caa7a287ac56d3d3d6819f7b6631915028fda55324c101e9cf676",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.chat.turnTail",
|
||||
"conversation.composer.dock",
|
||||
"conversation.input.dock",
|
||||
"conversation.input.overlay",
|
||||
"conversation.session.header.actions",
|
||||
"conversation.view",
|
||||
"plugins.bundle.config",
|
||||
"settings.general.item",
|
||||
"settings.section",
|
||||
"sidebar.footer.action",
|
||||
"sidebar.right.pane.tab",
|
||||
"sidebar.workspaces.session.row.action"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"usage-stream-readonly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 22,
|
||||
"package": "@changfenhuang/dsh-genui",
|
||||
"version": "0.16.0",
|
||||
"family": "panel (genui)",
|
||||
"tarball": "changfenhuang-dsh-genui-0.16.0.tgz",
|
||||
"tarball_sha256": "faedeb35fda615f664d217c34d7d23ff076003294e16258352a5dc446d6a1be6",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.input.dock",
|
||||
"tool.call.toolview"
|
||||
],
|
||||
"named_brokers_needed": []
|
||||
},
|
||||
{
|
||||
"row": 23,
|
||||
"package": "dsh-better-sidebar",
|
||||
"version": "0.30.1",
|
||||
"family": "sidebar/panel",
|
||||
"tarball": "dsh-better-sidebar-0.30.1.tgz",
|
||||
"tarball_sha256": "3b3deafd3096fdf42b271c4cda0f0e70f024d4f8ef55ffd523b42c6e6d79b41e",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.session.header.utilities",
|
||||
"main.conversation",
|
||||
"settings.section",
|
||||
"sidebar.right.pane.tab"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm",
|
||||
"workspace-overlay-rw"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 24,
|
||||
"package": "@michengai/dsh-codex-ui",
|
||||
"version": "2.0.21",
|
||||
"family": "web shell/panel",
|
||||
"tarball": "michengai-dsh-codex-ui-2.0.21.tgz",
|
||||
"tarball_sha256": "3c4e1c95652ddc184dc5a581d2de443cf60f2d200ac1f03ae91f8c920fed22ea",
|
||||
"admission": "admit",
|
||||
"admission_basis": "engines.dsh",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"contextMenu.icon",
|
||||
"conversation.input.dock",
|
||||
"conversation.input.overlay",
|
||||
"settings.section",
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 25,
|
||||
"package": "dsh-pet",
|
||||
"version": "0.4.7",
|
||||
"family": "panel + llm",
|
||||
"tarball": "dsh-pet-0.4.7.tgz",
|
||||
"tarball_sha256": "5337aeb04ce13e230297d96cc8873d49cc8463c807d871a94b9234e1301b579d",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"settings.section",
|
||||
"sidebar.right.pane.tab"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"llm",
|
||||
"credentials",
|
||||
"egress"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 26,
|
||||
"package": "dsh-whale-widget",
|
||||
"version": "0.4.2",
|
||||
"family": "panel + account",
|
||||
"tarball": "dsh-whale-widget-0.4.2.tgz",
|
||||
"tarball_sha256": "2368de8192c4ba28682a6344f78ace11cb41bac397c13981e36dfca49c0ebc78",
|
||||
"admission": "admit",
|
||||
"admission_basis": "no-dsh-declaration",
|
||||
"declares_client": false,
|
||||
"declared_dsh_slot_keys": [],
|
||||
"named_brokers_needed": [
|
||||
"deepseekAccount-balance-readonly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 27,
|
||||
"package": "@linxin666/dsh-client-ui-skill-explorer",
|
||||
"version": "0.4.16",
|
||||
"family": "panel",
|
||||
"tarball": "linxin666-dsh-client-ui-skill-explorer-0.4.16.tgz",
|
||||
"tarball_sha256": "470064ce42067513c0df08a7159ba853ba4c4a7e2bbce678e4062ac8443b78ce",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"fs-read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 28,
|
||||
"package": "@linxin666/dsh-client-ui-git-graph",
|
||||
"version": "0.4.14",
|
||||
"family": "panel",
|
||||
"tarball": "linxin666-dsh-client-ui-git-graph-0.4.14.tgz",
|
||||
"tarball_sha256": "5fd0c917316651f303a0bf49784c15aa02e25491f9f05f0289de016288ebab0f",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.input.dock"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"git-exec-readonly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 29,
|
||||
"package": "dsh-archive-manager",
|
||||
"version": "1.1.1",
|
||||
"family": "panel",
|
||||
"tarball": "dsh-archive-manager-1.1.1.tgz",
|
||||
"tarball_sha256": "011d9943494645318f3e893174998cf1ebdecd94ce87ff6d04f9815b128b1a0a",
|
||||
"admission": "refuse",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"sidebar.footer.action",
|
||||
"sidebar.workspaces"
|
||||
],
|
||||
"named_brokers_needed": [],
|
||||
"refusal": {
|
||||
"code": "incompatible-version",
|
||||
"field": "peerDependencies.@deepseek-ai/dsh-agent",
|
||||
"range": "^0.1.0-rc.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"row": 30,
|
||||
"package": "dshmarket",
|
||||
"version": "1.73.17",
|
||||
"family": "market (Engine-brokered)",
|
||||
"tarball": "dshmarket-1.73.17.tgz",
|
||||
"tarball_sha256": "b66be433c7952fbbd57d80be47fac82a873039f542ef4457cf858642f8da9564",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config",
|
||||
"plugins.detail.section",
|
||||
"plugins.tab",
|
||||
"settings.plugin.item",
|
||||
"settings.plugins.tab",
|
||||
"settings.section",
|
||||
"sidebar.panellist"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"reviewed-install"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 31,
|
||||
"package": "@deepseek-harness-tui/dsh-tui",
|
||||
"version": "0.19.0",
|
||||
"family": "terminal surface (Engine-brokered)",
|
||||
"tarball": "deepseek-harness-tui-dsh-tui-0.19.0.tgz",
|
||||
"tarball_sha256": "a5a41b24c69fc68f868ce4d5637feaef227cb7b34a177c0625d938eecab22da7",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": false,
|
||||
"declared_dsh_slot_keys": [
|
||||
"settings.plugin.item"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"terminal-surface",
|
||||
"llm",
|
||||
"approval"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 32,
|
||||
"package": "dsh-pocket",
|
||||
"version": "2.12.0",
|
||||
"family": "remote/mobile surface (Engine-brokered)",
|
||||
"tarball": "dsh-pocket-2.12.0.tgz",
|
||||
"tarball_sha256": "aa75689af6a2ded126248e0704ab5000224d2bb755734aa0a2d1aecb41b796d0",
|
||||
"admission": "admit",
|
||||
"admission_basis": "no-dsh-declaration",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"conversation.composer.dock",
|
||||
"conversation.session.header.actions",
|
||||
"settings.section",
|
||||
"sidebar.footer.action"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"remote-surface",
|
||||
"licence-review-GPL-2.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 33,
|
||||
"package": "dsh-plugin-model-proxy",
|
||||
"version": "0.1.6",
|
||||
"family": "provider proxy (Engine-brokered)",
|
||||
"tarball": "dsh-plugin-model-proxy-0.1.6.tgz",
|
||||
"tarball_sha256": "9a27b24e0fd6152fb6e7b005821298a6eb8d4c4a05cec2ef37fc6b082c2ddf62",
|
||||
"admission": "admit",
|
||||
"admission_basis": "peerDependencies",
|
||||
"declares_client": true,
|
||||
"declared_dsh_slot_keys": [
|
||||
"plugins.bundle.config"
|
||||
],
|
||||
"named_brokers_needed": [
|
||||
"provider-routes"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"direction": "host_to_core",
|
||||
"valid": true,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {
|
||||
"plugin_id": "dsh-workspace-deps",
|
||||
"generation": 3,
|
||||
"owner_token": "5f0c6a3e9b2d4c1a8e7f60123456789a"
|
||||
},
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"service": "workspace",
|
||||
"method": "read",
|
||||
"input": {
|
||||
"path": "README.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"direction": "host_to_core",
|
||||
"valid": false,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {
|
||||
"plugin_id": "dsh-workspace-deps",
|
||||
"generation": 3,
|
||||
"owner_token": "5f0c6a3e9b2d4c1a8e7f60123456789a"
|
||||
},
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"service": "workspace",
|
||||
"method": "read",
|
||||
"input": {
|
||||
"path": "README.md"
|
||||
},
|
||||
"session_id": "foreign"
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"direction": "host_to_core",
|
||||
"valid": false,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {
|
||||
"plugin_id": "dsh-workspace-deps",
|
||||
"generation": 3,
|
||||
"owner_token": "5f0c6a3e9b2d4c1a8e7f60123456789a"
|
||||
},
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"method": "read",
|
||||
"input": {
|
||||
"path": "README.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"direction": "host_to_core",
|
||||
"valid": false,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {
|
||||
"plugin_id": "dsh-workspace-deps",
|
||||
"generation": 3,
|
||||
"owner_token": "5f0c6a3e9b2d4c1a8e7f60123456789a"
|
||||
},
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"service": "workspace",
|
||||
"method": "read",
|
||||
"input": {
|
||||
"path": "README.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"direction": "host_to_core",
|
||||
"valid": false,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {
|
||||
"plugin_id": "dsh-workspace-deps",
|
||||
"generation": 3,
|
||||
"owner_token": "5f0c6a3e9b2d4c1a8e7f60123456789a"
|
||||
},
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"service": 7,
|
||||
"method": "read",
|
||||
"input": {
|
||||
"path": "README.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"direction": "core_to_host",
|
||||
"valid": false,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "dsh/broker",
|
||||
"params": {
|
||||
"owner": {
|
||||
"plugin_id": "dsh-workspace-deps",
|
||||
"generation": 3,
|
||||
"owner_token": "5f0c6a3e9b2d4c1a8e7f60123456789a"
|
||||
},
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"service": "workspace",
|
||||
"method": "read",
|
||||
"input": {
|
||||
"path": "README.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"direction": "core_to_host",
|
||||
"valid": true,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tool/call",
|
||||
"params": {
|
||||
"handle": 17,
|
||||
"call_id": "call-2",
|
||||
"input": {},
|
||||
"deadline_ms": 120000,
|
||||
"workspace": "/home/u/project",
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"session_id": "session-1",
|
||||
"broker_tickets": [
|
||||
{
|
||||
"service": "workspace",
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"direction": "core_to_host",
|
||||
"valid": false,
|
||||
"frame": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tool/call",
|
||||
"params": {
|
||||
"handle": 17,
|
||||
"call_id": "call-2",
|
||||
"input": {},
|
||||
"deadline_ms": 120000,
|
||||
"workspace": "/home/u/project",
|
||||
"ticket": "cwt.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"session_id": "session-1",
|
||||
"broker_tickets": [
|
||||
{
|
||||
"service": "workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
use agent_client_protocol_schema::v1 as acp;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Side {
|
||||
Client,
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Side::Client => "client",
|
||||
Side::Agent => "agent",
|
||||
}
|
||||
}
|
||||
fn peer(self) -> Side {
|
||||
match self {
|
||||
Side::Client => Side::Agent,
|
||||
Side::Agent => Side::Client,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Allowed {
|
||||
pub side: Side,
|
||||
pub frame: &'static str,
|
||||
pub problem: &'static str,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub struct Conformance {
|
||||
checked: Vec<Side>,
|
||||
pending: HashMap<(Side, String), String>,
|
||||
findings: BTreeSet<(Side, String, String)>,
|
||||
frames: usize,
|
||||
}
|
||||
|
||||
fn check<T: DeserializeOwned + Serialize>(value: &Value) -> Vec<String> {
|
||||
let typed: T = match serde_json::from_value(value.clone()) {
|
||||
Ok(typed) => typed,
|
||||
Err(error) => {
|
||||
let text = error.to_string();
|
||||
let text = text.split(" at line ").next().unwrap_or(&text);
|
||||
return vec![format!("not a valid {}: {text}", short_type::<T>())];
|
||||
}
|
||||
};
|
||||
let back = match serde_json::to_value(&typed) {
|
||||
Ok(back) => back,
|
||||
Err(error) => return vec![format!("does not re-serialize: {error}")],
|
||||
};
|
||||
let mut problems = Vec::new();
|
||||
diff("", value, &back, &mut problems);
|
||||
problems
|
||||
}
|
||||
|
||||
fn short_type<T>() -> &'static str {
|
||||
let name = std::any::type_name::<T>();
|
||||
name.rsplit("::").next().unwrap_or(name)
|
||||
}
|
||||
|
||||
fn skipped_default(key: &str, value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => true,
|
||||
Value::Array(items) => items.is_empty(),
|
||||
Value::Object(map) => key == "env" && map.is_empty(),
|
||||
Value::String(text) => matches!(
|
||||
(key, text.as_str()),
|
||||
("kind", "other") | ("status", "pending")
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff(path: &str, ours: &Value, official: &Value, problems: &mut Vec<String>) {
|
||||
match (ours, official) {
|
||||
(Value::Object(ours), Value::Object(official)) => {
|
||||
for (key, value) in ours {
|
||||
if key == "_meta" {
|
||||
continue;
|
||||
}
|
||||
let at = format!("{path}.{key}");
|
||||
match official.get(key) {
|
||||
Some(back) => diff(&at, value, back, problems),
|
||||
None if skipped_default(key, value) => {}
|
||||
None => problems.push(format!("field `{at}` is not in the official type")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(ours), Value::Array(official)) if ours.len() == official.len() => {
|
||||
for (value, back) in ours.iter().zip(official) {
|
||||
diff(&format!("{path}[]"), value, back, problems);
|
||||
}
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) if a.as_f64() == b.as_f64() => {}
|
||||
_ if ours == official => {}
|
||||
_ => problems.push(format!(
|
||||
"value at `{}` reads back as {official} instead of {ours}",
|
||||
if path.is_empty() { "." } else { path }
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_params(side: Side, method: &str, params: &Value) -> Option<Vec<String>> {
|
||||
Some(match (side, method) {
|
||||
(Side::Client, "initialize") => check::<acp::InitializeRequest>(params),
|
||||
(Side::Client, "authenticate") => check::<acp::AuthenticateRequest>(params),
|
||||
(Side::Client, "session/new") => check::<acp::NewSessionRequest>(params),
|
||||
(Side::Client, "session/load") => check::<acp::LoadSessionRequest>(params),
|
||||
(Side::Client, "session/resume") => check::<acp::ResumeSessionRequest>(params),
|
||||
(Side::Client, "session/list") => check::<acp::ListSessionsRequest>(params),
|
||||
(Side::Client, "session/close") => check::<acp::CloseSessionRequest>(params),
|
||||
(Side::Client, "session/delete") => check::<acp::DeleteSessionRequest>(params),
|
||||
(Side::Client, "session/set_mode") => check::<acp::SetSessionModeRequest>(params),
|
||||
(Side::Client, "session/set_config_option") => {
|
||||
check::<acp::SetSessionConfigOptionRequest>(params)
|
||||
}
|
||||
(Side::Client, "session/prompt") => check::<acp::PromptRequest>(params),
|
||||
(Side::Agent, "session/request_permission") => {
|
||||
check::<acp::RequestPermissionRequest>(params)
|
||||
}
|
||||
(Side::Agent, "fs/read_text_file") => check::<acp::ReadTextFileRequest>(params),
|
||||
(Side::Agent, "fs/write_text_file") => check::<acp::WriteTextFileRequest>(params),
|
||||
(Side::Agent, "terminal/create") => check::<acp::CreateTerminalRequest>(params),
|
||||
(Side::Agent, "terminal/output") => check::<acp::TerminalOutputRequest>(params),
|
||||
(Side::Agent, "terminal/release") => check::<acp::ReleaseTerminalRequest>(params),
|
||||
(Side::Agent, "terminal/wait_for_exit") => check::<acp::WaitForTerminalExitRequest>(params),
|
||||
(Side::Agent, "terminal/kill") => check::<acp::KillTerminalRequest>(params),
|
||||
(Side::Agent, "elicitation/create") => check::<acp::CreateElicitationRequest>(params),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn response_result(method: &str, result: &Value) -> Option<Vec<String>> {
|
||||
Some(match method {
|
||||
"initialize" => check::<acp::InitializeResponse>(result),
|
||||
"authenticate" => check::<acp::AuthenticateResponse>(result),
|
||||
"session/new" => check::<acp::NewSessionResponse>(result),
|
||||
"session/load" => check::<acp::LoadSessionResponse>(result),
|
||||
"session/resume" => check::<acp::ResumeSessionResponse>(result),
|
||||
"session/list" => check::<acp::ListSessionsResponse>(result),
|
||||
"session/close" => check::<acp::CloseSessionResponse>(result),
|
||||
"session/delete" => check::<acp::DeleteSessionResponse>(result),
|
||||
"session/set_mode" => check::<acp::SetSessionModeResponse>(result),
|
||||
"session/set_config_option" => check::<acp::SetSessionConfigOptionResponse>(result),
|
||||
"session/prompt" => check::<acp::PromptResponse>(result),
|
||||
"session/request_permission" => check::<acp::RequestPermissionResponse>(result),
|
||||
"fs/read_text_file" => check::<acp::ReadTextFileResponse>(result),
|
||||
"fs/write_text_file" => check::<acp::WriteTextFileResponse>(result),
|
||||
"terminal/create" => check::<acp::CreateTerminalResponse>(result),
|
||||
"terminal/output" => check::<acp::TerminalOutputResponse>(result),
|
||||
"terminal/release" => check::<acp::ReleaseTerminalResponse>(result),
|
||||
"terminal/wait_for_exit" => check::<acp::WaitForTerminalExitResponse>(result),
|
||||
"terminal/kill" => check::<acp::KillTerminalResponse>(result),
|
||||
"elicitation/create" => check::<acp::CreateElicitationResponse>(result),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn notification_params(side: Side, method: &str, params: &Value) -> Option<Vec<String>> {
|
||||
Some(match (side, method) {
|
||||
(_, "$/cancel_request") => check::<acp::CancelRequestNotification>(params),
|
||||
(Side::Client, "session/cancel") => check::<acp::CancelNotification>(params),
|
||||
(Side::Agent, "session/update") => check::<acp::SessionNotification>(params),
|
||||
(Side::Agent, "elicitation/complete") => {
|
||||
check::<acp::CompleteElicitationNotification>(params)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn envelope(frame: &Value, allowed: &[&str]) -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
if frame.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
|
||||
problems.push("envelope lacks `jsonrpc: \"2.0\"`".to_string());
|
||||
}
|
||||
if let Some(map) = frame.as_object() {
|
||||
for key in map.keys() {
|
||||
if !allowed.contains(&key.as_str()) && key != "jsonrpc" {
|
||||
problems.push(format!("envelope carries `{key}`"));
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
impl Conformance {
|
||||
pub fn checking(sides: &[Side]) -> Self {
|
||||
Self {
|
||||
checked: sides.to_vec(),
|
||||
pending: HashMap::new(),
|
||||
findings: BTreeSet::new(),
|
||||
frames: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, side: Side, frame: &Value) {
|
||||
self.frames += 1;
|
||||
let method = frame.get("method").and_then(Value::as_str);
|
||||
let id = frame.get("id");
|
||||
let params = frame.get("params").cloned().unwrap_or(Value::Null);
|
||||
let (label, mut problems) = match (method, id) {
|
||||
(Some(method), Some(id)) => {
|
||||
self.pending
|
||||
.insert((side, id.to_string()), method.to_string());
|
||||
let mut problems = envelope(frame, &["id", "method", "params"]);
|
||||
match request_params(side, method, ¶ms) {
|
||||
Some(found) => problems.extend(found),
|
||||
None if method.starts_with('_') => {}
|
||||
None => problems.push(format!(
|
||||
"`{method}` is not a {} request in official v1",
|
||||
side.name()
|
||||
)),
|
||||
}
|
||||
(format!("{method} request"), problems)
|
||||
}
|
||||
(Some(method), None) => {
|
||||
let mut problems = envelope(frame, &["method", "params"]);
|
||||
match notification_params(side, method, ¶ms) {
|
||||
Some(found) => problems.extend(found),
|
||||
None if method.starts_with('_') => {}
|
||||
None => problems.push(format!(
|
||||
"`{method}` is not a {} notification in official v1",
|
||||
side.name()
|
||||
)),
|
||||
}
|
||||
(format!("{method} notification"), problems)
|
||||
}
|
||||
(None, Some(id)) => {
|
||||
let answered = self.pending.remove(&(side.peer(), id.to_string()));
|
||||
let label = match &answered {
|
||||
Some(method) => format!("{method} response"),
|
||||
None => "response".to_string(),
|
||||
};
|
||||
let mut problems = Vec::new();
|
||||
if let Some(error) = frame.get("error") {
|
||||
problems.extend(envelope(frame, &["id", "error"]));
|
||||
problems.extend(check::<acp::Error>(error));
|
||||
} else if let Some(result) = frame.get("result") {
|
||||
problems.extend(envelope(frame, &["id", "result"]));
|
||||
if let Some(found) = answered
|
||||
.as_deref()
|
||||
.and_then(|method| response_result(method, result))
|
||||
{
|
||||
problems.extend(found);
|
||||
}
|
||||
} else {
|
||||
problems.push("response has neither result nor error".to_string());
|
||||
}
|
||||
if answered.is_none() {
|
||||
problems.push(format!("answers no outstanding request (id {id})"));
|
||||
}
|
||||
(label, problems)
|
||||
}
|
||||
(None, None) => (
|
||||
"frame".to_string(),
|
||||
vec!["neither a request, a notification nor a response".to_string()],
|
||||
),
|
||||
};
|
||||
let label = match frame
|
||||
.pointer("/params/update/sessionUpdate")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some(kind) => format!("{label} ({kind})"),
|
||||
None => label,
|
||||
};
|
||||
if self.checked.contains(&side) {
|
||||
for problem in problems.drain(..) {
|
||||
self.findings.insert((side, label.clone(), problem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_only(&self, allowed: &[Allowed]) {
|
||||
assert!(self.frames > 0, "no frames were observed");
|
||||
let mut used = vec![false; allowed.len()];
|
||||
let mut unexpected = Vec::new();
|
||||
for (side, frame, problem) in &self.findings {
|
||||
match allowed.iter().position(|entry| {
|
||||
entry.side == *side && entry.frame == frame && problem.contains(entry.problem)
|
||||
}) {
|
||||
Some(index) => used[index] = true,
|
||||
None => unexpected.push(format!("{} {frame}: {problem}", side.name())),
|
||||
}
|
||||
}
|
||||
let stale: Vec<_> = allowed
|
||||
.iter()
|
||||
.zip(&used)
|
||||
.filter(|(_, used)| !**used)
|
||||
.map(|(entry, _)| {
|
||||
format!(
|
||||
"{} {}: {} ({})",
|
||||
entry.side.name(),
|
||||
entry.frame,
|
||||
entry.problem,
|
||||
entry.reason
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
unexpected.is_empty() && stale.is_empty(),
|
||||
"ACP frames differ from the official v1 schema.\nnew deviations:\n {}\nallowlisted deviations no longer seen (remove them):\n {}",
|
||||
unexpected.join("\n "),
|
||||
stale.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
pub fn frames(&self) -> usize {
|
||||
self.frames
|
||||
}
|
||||
}
|
||||
@@ -1147,6 +1147,8 @@ mod tests {
|
||||
// 65,536 output (verified 2026-08-17, see
|
||||
// `known_context_window_for_model`).
|
||||
for model in [
|
||||
"gemini-4-carbon",
|
||||
"gemini-4-argon",
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.5-flash",
|
||||
|
||||
@@ -513,6 +513,7 @@ pub struct DshConversion {
|
||||
/// Ordinary reviewed Native entry; remains experimental and disabled at install.
|
||||
pub requires_native: bool,
|
||||
pub native_rows: Vec<String>,
|
||||
pub client_closures: Vec<DshClientClosure>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1111,6 +1112,309 @@ fn declares_client(manifest: &Value) -> bool {
|
||||
.is_some_and(|client| !matches!(client, Value::Null))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct DshClientClosure {
|
||||
pub package: String,
|
||||
pub root: String,
|
||||
pub entry: String,
|
||||
pub platform: Option<String>,
|
||||
pub inject: Vec<String>,
|
||||
pub host_provided: Vec<String>,
|
||||
pub files: BTreeMap<String, String>,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
const CLIENT_JS: [&str; 3] = ["js", "mjs", "cjs"];
|
||||
const CLIENT_PROBES: [&str; 8] = [
|
||||
"",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
"/index.js",
|
||||
"/index.mjs",
|
||||
"/index.cjs",
|
||||
];
|
||||
const NODE_BUILTINS: [&str; 24] = [
|
||||
"assert",
|
||||
"buffer",
|
||||
"child_process",
|
||||
"cluster",
|
||||
"crypto",
|
||||
"dgram",
|
||||
"dns",
|
||||
"events",
|
||||
"fs",
|
||||
"http",
|
||||
"http2",
|
||||
"https",
|
||||
"module",
|
||||
"net",
|
||||
"os",
|
||||
"path",
|
||||
"process",
|
||||
"readline",
|
||||
"stream",
|
||||
"tls",
|
||||
"url",
|
||||
"util",
|
||||
"vm",
|
||||
"zlib",
|
||||
];
|
||||
|
||||
fn normalize_slash(path: &str) -> Option<String> {
|
||||
if path.contains(['\\', '\0']) {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in path.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
other => parts.push(other),
|
||||
}
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
fn client_entry(manifest: &Value) -> String {
|
||||
let target = manifest
|
||||
.get("exports")
|
||||
.and_then(|exports| exports.get("./client"))
|
||||
.and_then(|export| match export {
|
||||
Value::Str(target) => Some(target.as_str()),
|
||||
Value::Map(_) => ["import", "default", "."]
|
||||
.iter()
|
||||
.find_map(|key| export.get(key).and_then(Value::str)),
|
||||
_ => None,
|
||||
});
|
||||
target.unwrap_or("client.js").to_string()
|
||||
}
|
||||
|
||||
fn package_target(manifest: &Value, subpath: &str) -> Option<String> {
|
||||
let key = if subpath.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
format!("./{subpath}")
|
||||
};
|
||||
let from_exports = manifest.get("exports").and_then(|exports| {
|
||||
let slot = if matches!(exports, Value::Map(_))
|
||||
&& exports.keys().iter().any(|name| name.starts_with('.'))
|
||||
{
|
||||
exports.get(&key)
|
||||
} else if subpath.is_empty() {
|
||||
Some(exports)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match slot? {
|
||||
Value::Str(target) => Some(target.clone()),
|
||||
conditions @ Value::Map(_) => ["import", "browser", "require", "default"]
|
||||
.iter()
|
||||
.find_map(|name| conditions.get(name).and_then(Value::str))
|
||||
.map(str::to_string),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
from_exports.or_else(|| {
|
||||
if subpath.is_empty() {
|
||||
Some(
|
||||
manifest
|
||||
.get("main")
|
||||
.and_then(Value::str)
|
||||
.unwrap_or("index.js")
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
Some(subpath.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
struct ClientScan<'a> {
|
||||
files: &'a BTreeMap<String, Vec<u8>>,
|
||||
closure: BTreeMap<String, String>,
|
||||
host: std::collections::BTreeSet<String>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ClientScan<'_> {
|
||||
fn probe(&self, base: &str) -> Option<String> {
|
||||
CLIENT_PROBES
|
||||
.iter()
|
||||
.map(|suffix| format!("{base}{suffix}"))
|
||||
.find(|candidate| {
|
||||
candidate.starts_with("source/") && self.files.contains_key(candidate.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(&mut self, from: &str, specifier: &str) -> Result<Option<String>, String> {
|
||||
let directory = from.rsplit_once('/').map_or("", |(dir, _)| dir);
|
||||
if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
|
||||
let base = normalize_slash(&format!("{directory}/{specifier}"))
|
||||
.ok_or_else(|| format!("`{specifier}` is outside the reviewed package files"))?;
|
||||
return self
|
||||
.probe(&base)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("`{specifier}` is not in the reviewed package files"));
|
||||
}
|
||||
if specifier.starts_with('/') || specifier.contains(':') || specifier.is_empty() {
|
||||
return Err(format!(
|
||||
"`{specifier}` is an absolute, URL or built-in specifier"
|
||||
));
|
||||
}
|
||||
let (name, subpath) = match specifier.split_once('/') {
|
||||
Some((scope, rest)) if scope.starts_with('@') => match rest.split_once('/') {
|
||||
Some((leaf, sub)) => (format!("{scope}/{leaf}"), sub.to_string()),
|
||||
None => (specifier.to_string(), String::new()),
|
||||
},
|
||||
Some((head, sub)) => (head.to_string(), sub.to_string()),
|
||||
None => (specifier.to_string(), String::new()),
|
||||
};
|
||||
if NODE_BUILTINS.contains(&name.as_str()) {
|
||||
return Err(format!("`{specifier}` is a Node built-in"));
|
||||
}
|
||||
let mut current = directory.to_string();
|
||||
loop {
|
||||
let package = format!("{current}/node_modules/{name}");
|
||||
if let Some(bytes) = self.files.get(&format!("{package}/package.json")) {
|
||||
let manifest = std::str::from_utf8(bytes)
|
||||
.ok()
|
||||
.and_then(|text| parse_json(text).ok())
|
||||
.ok_or_else(|| format!("`{name}` has an unreadable package.json"))?;
|
||||
let target = package_target(&manifest, &subpath)
|
||||
.and_then(|target| normalize_slash(&format!("{package}/{target}")))
|
||||
.filter(|path| path.starts_with(&format!("{package}/")))
|
||||
.ok_or_else(|| format!("`{specifier}` has no contained entry"))?;
|
||||
self.note(format!("{package}/package.json"));
|
||||
return self
|
||||
.probe(&target)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("`{specifier}` is not in the reviewed package files"));
|
||||
}
|
||||
match current.rsplit_once('/') {
|
||||
Some((parent, _)) => current = parent.to_string(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if name == "react" || name == "react-dom" || name.starts_with("@deepseek-ai/") {
|
||||
self.host.insert(name);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(format!(
|
||||
"`{specifier}` is an ambient dependency that is not in the reviewed bundle"
|
||||
))
|
||||
}
|
||||
|
||||
fn note(&mut self, path: String) {
|
||||
if let Some(bytes) = self.files.get(&path)
|
||||
&& !self.closure.contains_key(&path)
|
||||
{
|
||||
self.bytes += bytes.len();
|
||||
self.closure.insert(path, sha256_hex(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit(&mut self, entry: String) -> Result<(), String> {
|
||||
let load = pattern(
|
||||
r#"(?m)(?:\brequire\s*\(\s*|\bimport\s*\(\s*|(?:^|[;{}])\s*import\s*|\b(?:import|export)\b[^;"'`()]*?\bfrom\b\s*)(?:"([^"\n]*)"|'([^'\n]*)')"#,
|
||||
);
|
||||
let dynamic = pattern(
|
||||
r#"\b(?:require|import)\s*\(\s*(?:[^\s"'\)]|"[^"\n]*"\s*[^\s,\)]|'[^'\n]*'\s*[^\s,\)])"#,
|
||||
);
|
||||
let mut pending = vec![entry];
|
||||
while let Some(path) = pending.pop() {
|
||||
if self.closure.contains_key(&path) {
|
||||
continue;
|
||||
}
|
||||
require(self.closure.len() < MAX_FILES, "too many client files")
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.note(path.clone());
|
||||
let extension = path
|
||||
.rsplit_once('.')
|
||||
.map(|(_, ext)| ext.to_ascii_lowercase());
|
||||
if !extension
|
||||
.as_deref()
|
||||
.is_some_and(|ext| CLIENT_JS.contains(&ext))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::str::from_utf8(&self.files[&path])
|
||||
.map_err(|_| format!("`{path}` is not UTF-8"))?
|
||||
.to_string();
|
||||
if dynamic.is_match(&text) {
|
||||
return Err(format!("`{path}` loads a module by a computed specifier"));
|
||||
}
|
||||
for capture in load.captures_iter(&text) {
|
||||
let specifier = capture
|
||||
.get(1)
|
||||
.or_else(|| capture.get(2))
|
||||
.map_or("", |m| m.as_str());
|
||||
if let Some(resolved) = self.resolve(&path, specifier)? {
|
||||
pending.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn client_closure(
|
||||
files: &BTreeMap<String, Vec<u8>>,
|
||||
root: &str,
|
||||
manifest: &Value,
|
||||
) -> Result<DshClientClosure, String> {
|
||||
let prefix = if root.is_empty() {
|
||||
"source".to_string()
|
||||
} else {
|
||||
format!("source/{root}")
|
||||
};
|
||||
let raw = client_entry(manifest);
|
||||
let entry = normalize_slash(&format!("{prefix}/{raw}"))
|
||||
.filter(|path| path.starts_with(&format!("{prefix}/")))
|
||||
.ok_or_else(|| format!("client entry `{raw}` leaves the package"))?;
|
||||
if !files.contains_key(&entry) {
|
||||
return Err(format!(
|
||||
"client entry `{raw}` is not in the reviewed package files"
|
||||
));
|
||||
}
|
||||
let mut scan = ClientScan {
|
||||
files,
|
||||
closure: BTreeMap::new(),
|
||||
host: Default::default(),
|
||||
bytes: 0,
|
||||
};
|
||||
scan.note(format!("{prefix}/package.json"));
|
||||
scan.visit(entry.clone())?;
|
||||
let client = manifest.get("dsh").and_then(|dsh| dsh.get("client"));
|
||||
let inject = match client.and_then(|client| client.get("inject")) {
|
||||
Some(Value::Seq(items)) => items
|
||||
.iter()
|
||||
.filter_map(|item| item.str().map(refusal_text))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(DshClientClosure {
|
||||
package: manifest
|
||||
.get("name")
|
||||
.and_then(Value::str)
|
||||
.map(refusal_text)
|
||||
.unwrap_or_default(),
|
||||
root: root.to_string(),
|
||||
entry,
|
||||
platform: client
|
||||
.and_then(|client| client.get("platform"))
|
||||
.and_then(Value::str)
|
||||
.map(refusal_text),
|
||||
inject,
|
||||
host_provided: scan.host.into_iter().collect(),
|
||||
files: scan.closure,
|
||||
bytes: scan.bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
|
||||
let manifest_bytes = read_file(&package.contained(Path::new("package.json"))?, MAX_DOCUMENT)
|
||||
.map_err(|_| {
|
||||
@@ -1127,12 +1431,6 @@ pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
|
||||
let admitted = admit_host_compatibility(&manifest)?;
|
||||
let dsh_range = admitted.dsh_range.as_deref();
|
||||
let mut notes = Vec::new();
|
||||
if declares_client(&manifest) {
|
||||
notes.push(
|
||||
"package declares `dsh.client`; its UI half was not converted and remains disabled because the DSH client runtime is not supported"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let declared: Vec<&Value> = match bundle.and_then(|bundle| bundle.get("patch")) {
|
||||
Some(single @ Value::Str(_)) => vec![single],
|
||||
Some(Value::Seq(items)) if !items.is_empty() => items.iter().collect(),
|
||||
@@ -2310,6 +2608,7 @@ fn native_composition_files(
|
||||
mut document: Json,
|
||||
output: &mut OutputFiles,
|
||||
client_outcomes: &mut Vec<DshOutcome>,
|
||||
client_closures: &mut Vec<DshClientClosure>,
|
||||
ui: bool,
|
||||
) -> Result<Vec<String>> {
|
||||
fn modules(rows: &[Value], names: &mut Vec<String>) -> Result<()> {
|
||||
@@ -2387,7 +2686,10 @@ fn native_composition_files(
|
||||
"Native Node addons (`.node`) are not supported in DSH bundles.",
|
||||
)?;
|
||||
require(
|
||||
(dependency || !lower.starts_with('.') || lower == ".agent-presets")
|
||||
(dependency
|
||||
|| !lower.starts_with('.')
|
||||
|| lower == ".agent-presets"
|
||||
|| relative == ".gitignore")
|
||||
&& !matches!(
|
||||
lower.as_str(),
|
||||
"credentials"
|
||||
@@ -2414,10 +2716,12 @@ fn native_composition_files(
|
||||
output.add(format!("source/{relative}"), bytes)
|
||||
})?;
|
||||
let mut selected_manifests = std::collections::BTreeSet::new();
|
||||
let mut client_paths: Vec<_> = paths
|
||||
.iter()
|
||||
.map(|(name, path)| (name.clone(), path.clone()))
|
||||
.collect();
|
||||
let mut client_paths: Vec<_> = vec![(String::new(), package.root.join("index.js"))];
|
||||
client_paths.extend(
|
||||
paths
|
||||
.iter()
|
||||
.map(|(name, path)| (name.clone(), path.clone())),
|
||||
);
|
||||
for name in &names {
|
||||
if name == presets::AGENT_PRESETS || host_hook_bridge(name) {
|
||||
let manifest = format!("source/node_modules/{name}/package.json");
|
||||
@@ -2435,7 +2739,7 @@ fn native_composition_files(
|
||||
let manifest_path = relative_slash(&parent.join("package.json"), &package.root);
|
||||
let key = format!("source/{manifest_path}");
|
||||
if let Some(bytes) = output.files.get(&key) {
|
||||
if selected_manifests.insert(manifest_path) {
|
||||
if selected_manifests.insert(manifest_path.clone()) {
|
||||
require(
|
||||
bytes.len() <= MAX_DOCUMENT,
|
||||
"Package manifest exceeds the conversion size limit.",
|
||||
@@ -2445,18 +2749,52 @@ fn native_composition_files(
|
||||
})?;
|
||||
let manifest = parse_json(text)?;
|
||||
if declares_client(&manifest) {
|
||||
client_outcomes.push(DshOutcome {
|
||||
row: Some(refusal_text(&name)),
|
||||
package: Some(refusal_text(
|
||||
manifest.get("name").and_then(Value::str).unwrap_or(&name),
|
||||
)),
|
||||
kind: "client".into(),
|
||||
outcome: "skipped".into(),
|
||||
reason: "Client UI was not converted; the DSH client runtime is not supported."
|
||||
.into(),
|
||||
layer: None,
|
||||
patch: None,
|
||||
});
|
||||
let root = manifest_path
|
||||
.rsplit_once('/')
|
||||
.map_or("", |(parent, _)| parent);
|
||||
let row = (!name.is_empty()).then(|| refusal_text(&name));
|
||||
let package_name = refusal_text(
|
||||
manifest.get("name").and_then(Value::str).unwrap_or(&name),
|
||||
);
|
||||
match client_closure(&output.files, root, &manifest) {
|
||||
Ok(closure) => {
|
||||
client_outcomes.push(DshOutcome {
|
||||
row,
|
||||
package: Some(package_name),
|
||||
kind: "client".into(),
|
||||
outcome: "retained".into(),
|
||||
reason: format!(
|
||||
"client closure reviewed and retained: entry {}, {} files, {} bytes{}; it is not served until the Engine client bootstrap admits it",
|
||||
refusal_text(&closure.entry),
|
||||
closure.files.len(),
|
||||
closure.bytes,
|
||||
if closure.host_provided.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
", host-provided modules {}",
|
||||
refusal_list(closure.host_provided.iter().cloned())
|
||||
)
|
||||
}
|
||||
),
|
||||
layer: None,
|
||||
patch: None,
|
||||
});
|
||||
client_closures.push(closure);
|
||||
}
|
||||
Err(reason) => client_outcomes.push(DshOutcome {
|
||||
row,
|
||||
package: Some(package_name),
|
||||
kind: "client".into(),
|
||||
outcome: "skipped".into(),
|
||||
reason: format!(
|
||||
"Client UI stays disabled: {}",
|
||||
refusal_text(&reason)
|
||||
),
|
||||
layer: None,
|
||||
patch: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -2549,6 +2887,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
|
||||
let loaded = load_bundle(&package)?;
|
||||
let mut output_files = OutputFiles::default();
|
||||
let mut client_outcomes = Vec::new();
|
||||
let mut client_closures = Vec::new();
|
||||
let ui = declares_client(&loaded.manifest);
|
||||
let native_rows = native_composition_files(
|
||||
&package,
|
||||
@@ -2556,6 +2895,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
|
||||
loaded.composition,
|
||||
&mut output_files,
|
||||
&mut client_outcomes,
|
||||
&mut client_closures,
|
||||
ui,
|
||||
)?;
|
||||
let requires_native = !native_rows.is_empty();
|
||||
@@ -2783,6 +3123,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
|
||||
requires_node,
|
||||
requires_native,
|
||||
native_rows,
|
||||
client_closures,
|
||||
};
|
||||
output_files.add(
|
||||
"CONVERSION.md".into(),
|
||||
@@ -2802,6 +3143,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
|
||||
"counts": {"skills": conversion.skills.len(), "mcp_servers": components.servers.len(), "native_rows": conversion.native_rows.len()},
|
||||
"requires_native": conversion.requires_native,
|
||||
"outcomes": conversion.outcomes,
|
||||
"client_closures": conversion.client_closures,
|
||||
"diagnostics": conversion.diagnostics,
|
||||
"required_manual_ports": conversion.outcomes.iter().filter(|o| o.needs_manual_port()).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
@@ -1597,3 +1597,158 @@ fn dsh_admission_validates_engines_peer_ranges_and_platforms_and_admits_cjs_and_
|
||||
.to_string();
|
||||
assert!(err.contains(".node"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsh_native_source_admits_a_top_level_gitignore_and_still_refuses_other_hidden_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let make = |name: &str| -> PathBuf {
|
||||
let dir = tmp.path().join(name);
|
||||
fs::create_dir_all(dir.join("lib")).unwrap();
|
||||
fs::write(
|
||||
dir.join("package.json"),
|
||||
serde_json::json!({
|
||||
"name": format!("@demo/{name}"),
|
||||
"version": "1.0.0",
|
||||
"main": "lib/index.cjs",
|
||||
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("cordis.patch.yml"),
|
||||
"- insert:\n - id: entry\n name: ./lib/index.cjs\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("lib/index.cjs"),
|
||||
"module.exports = { apply() {} };\n",
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
};
|
||||
|
||||
let with_gitignore = make("with-gitignore");
|
||||
fs::write(with_gitignore.join(".gitignore"), "node_modules/\n").unwrap();
|
||||
let out = tmp.path().join("out-with-gitignore");
|
||||
let converted = dsh::convert_package(&with_gitignore, &out).unwrap();
|
||||
assert!(converted.requires_native);
|
||||
assert!(out.join("source/.gitignore").is_file());
|
||||
|
||||
for (name, relative) in [
|
||||
("nested-gitignore", "lib/.gitignore"),
|
||||
("other-hidden", ".prettierrc"),
|
||||
("env-file", ".env"),
|
||||
("npmrc", ".npmrc"),
|
||||
("git-dir", ".git/config"),
|
||||
] {
|
||||
let bundle = make(name);
|
||||
let path = bundle.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "x\n").unwrap();
|
||||
let err = dsh::convert_package(&bundle, &tmp.path().join(format!("out-{name}")))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("credential files or repository metadata"),
|
||||
"{name}: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let linked = make("linked-gitignore");
|
||||
fs::write(tmp.path().join("outside-ignore"), "secret\n").unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path().join("outside-ignore"), linked.join(".gitignore"))
|
||||
.unwrap();
|
||||
let err = dsh::convert_package(&linked, &tmp.path().join("out-linked"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("links or reparse points"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsh_client_closure_is_retained_hashed_and_refuses_ambient_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let make = |name: &str, client: &str| -> PathBuf {
|
||||
let dir = tmp.path().join(name);
|
||||
fs::create_dir_all(dir.join("lib")).unwrap();
|
||||
fs::write(
|
||||
dir.join("package.json"),
|
||||
serde_json::json!({
|
||||
"name": format!("@demo/{name}"),
|
||||
"version": "1.0.0",
|
||||
"main": "lib/index.cjs",
|
||||
"exports": { "./client": { "default": "./lib/client.js" } },
|
||||
"dsh": {
|
||||
"bundle": { "patch": "./cordis.patch.yml" },
|
||||
"client": { "inject": ["@deepseek-ai/dsh-client-ui-primitives"], "platform": "web" }
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("cordis.patch.yml"),
|
||||
"- insert:\n - id: entry\n name: ./lib/index.cjs\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("lib/index.cjs"),
|
||||
"module.exports = { apply() {} };\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.join("lib/client.js"), client).unwrap();
|
||||
fs::write(dir.join("lib/util.js"), "exports.v = 1;\n").unwrap();
|
||||
dir
|
||||
};
|
||||
|
||||
let good = make(
|
||||
"good",
|
||||
"const r = require(\"react\");\nconst p = require('@deepseek-ai/dsh-client-ui-primitives');\nconst u = require(\"./util\");\n",
|
||||
);
|
||||
let converted = dsh::convert_package(&good, &tmp.path().join("out-good")).unwrap();
|
||||
assert_eq!(converted.client_closures.len(), 1);
|
||||
let closure = &converted.client_closures[0];
|
||||
assert_eq!(closure.entry, "source/lib/client.js");
|
||||
assert!(closure.files.contains_key("source/lib/util.js"));
|
||||
assert_eq!(closure.host_provided.len(), 2);
|
||||
let installed = fs::read(tmp.path().join("out-good/source/lib/util.js")).unwrap();
|
||||
assert_eq!(
|
||||
closure.files["source/lib/util.js"],
|
||||
codewhale_skill_install::install::sha256_hex(&installed)
|
||||
);
|
||||
assert!(
|
||||
converted
|
||||
.outcomes
|
||||
.iter()
|
||||
.any(|o| o.kind == "client" && o.outcome == "retained")
|
||||
);
|
||||
assert!(!converted.outcomes.iter().any(|o| o.needs_manual_port()));
|
||||
|
||||
for (label, source) in [
|
||||
("ambient", "require(\"left-pad\");\n"),
|
||||
("builtin", "require(\"node:fs\");\n"),
|
||||
("computed", "const n = \"x\"; require(n);\n"),
|
||||
("concat", "const l = \"en\"; require(\"./locale/\" + l);\n"),
|
||||
("concat-import", "const l = \"en\"; import(\"./a\" + l);\n"),
|
||||
("escape", "require(\"../../outside.js\");\n"),
|
||||
] {
|
||||
let bad = make(label, source);
|
||||
let converted =
|
||||
dsh::convert_package(&bad, &tmp.path().join(format!("out-{label}"))).unwrap();
|
||||
assert!(converted.client_closures.is_empty(), "{label}");
|
||||
assert!(
|
||||
converted
|
||||
.outcomes
|
||||
.iter()
|
||||
.any(|o| o.kind == "client" && o.outcome == "skipped"),
|
||||
"{label}"
|
||||
);
|
||||
assert!(
|
||||
converted.requires_native,
|
||||
"{label}: server half still imports"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -514,6 +514,8 @@ pub struct Settings {
|
||||
/// Omitted entirely before the first recorded use.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub footer_hint_uses: std::collections::BTreeMap<String, u8>,
|
||||
#[serde(default, skip_serializing_if = "AgentSettings::is_empty")]
|
||||
pub agents: AgentSettings,
|
||||
/// True only for the current load when `default_mode = "yolo"` was read
|
||||
/// from an older settings file. App startup uses this provenance to migrate
|
||||
/// the old bundled Full Access choice without weakening project or managed
|
||||
@@ -537,6 +539,81 @@ pub struct Settings {
|
||||
pub provenance: std::collections::BTreeMap<String, Layer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AgentSettings {
|
||||
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub quota: std::collections::BTreeMap<String, AgentQuotaSetting>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AgentQuotaSetting {
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub consented_at: Option<String>,
|
||||
}
|
||||
|
||||
pub const AGENT_QUOTA_KEY_PREFIX: &str = "agents.quota.";
|
||||
const MAX_AGENT_ID_CHARS: usize = 64;
|
||||
const MAX_AGENT_QUOTA_ENTRIES: usize = 32;
|
||||
|
||||
impl AgentSettings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.quota.is_empty()
|
||||
}
|
||||
|
||||
pub fn quota(&self, agent_id: &str) -> AgentQuotaSetting {
|
||||
self.quota.get(agent_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_quota_key(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
let rest = &key[AGENT_QUOTA_KEY_PREFIX.len()..];
|
||||
let (agent_id, consent) = match rest.strip_suffix(".consent") {
|
||||
Some(agent_id) => (agent_id, true),
|
||||
None => (rest, false),
|
||||
};
|
||||
let valid_id = !agent_id.is_empty()
|
||||
&& agent_id.len() <= MAX_AGENT_ID_CHARS
|
||||
&& agent_id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-');
|
||||
if !valid_id {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: `{key}` does not name an agent. Expected agents.quota.<agent> or agents.quota.<agent>.consent with a lowercase agent id."
|
||||
);
|
||||
}
|
||||
if !self.quota.contains_key(agent_id) && self.quota.len() >= MAX_AGENT_QUOTA_ENTRIES {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: `{key}` would exceed {MAX_AGENT_QUOTA_ENTRIES} agent quota entries. Turn one off first."
|
||||
);
|
||||
}
|
||||
let mut entry = self.quota(agent_id);
|
||||
if consent {
|
||||
entry.consented_at = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"granted" => Some(chrono::Utc::now().to_rfc3339()),
|
||||
"revoked" => None,
|
||||
_ => anyhow::bail!(
|
||||
"Failed to update setting: invalid consent for {key}. Expected: granted or revoked."
|
||||
),
|
||||
};
|
||||
} else {
|
||||
entry.enabled = parse_bool(value).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to update setting: invalid value for {key}. Expected: on or off."
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if entry == AgentQuotaSetting::default() {
|
||||
self.quota.remove(agent_id);
|
||||
} else {
|
||||
self.quota.insert(agent_id.to_string(), entry);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -622,6 +699,7 @@ impl Default for Settings {
|
||||
behavioral_tip_impressions: std::collections::BTreeMap::new(),
|
||||
dismissed_plugin_suggestions: std::collections::BTreeSet::new(),
|
||||
footer_hint_uses: std::collections::BTreeMap::new(),
|
||||
agents: AgentSettings::default(),
|
||||
legacy_yolo_default: false,
|
||||
tui_prefs_migration: None,
|
||||
provenance: std::collections::BTreeMap::new(),
|
||||
@@ -1790,6 +1868,9 @@ impl Settings {
|
||||
);
|
||||
}
|
||||
}
|
||||
quota if quota.starts_with(AGENT_QUOTA_KEY_PREFIX) => {
|
||||
self.agents.set_quota_key(quota, value)?;
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
|
||||
}
|
||||
@@ -3473,6 +3554,45 @@ mod tests {
|
||||
assert!(err.to_string().contains("off, scrim, or grain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_quota_keys_validate_cap_and_never_echo_the_value() {
|
||||
let mut settings = Settings::default();
|
||||
settings.set("agents.quota.claude", "on").unwrap();
|
||||
settings
|
||||
.set("agents.quota.claude.consent", "granted")
|
||||
.unwrap();
|
||||
assert!(settings.agents.quota("claude").enabled);
|
||||
assert!(settings.agents.quota("claude").consented_at.is_some());
|
||||
|
||||
let secret = "sk-do-not-echo";
|
||||
for (key, value) in [
|
||||
("agents.quota.claude", secret),
|
||||
("agents.quota.claude.consent", secret),
|
||||
("agents.quota.Claude", "on"),
|
||||
("agents.quota.", "on"),
|
||||
] {
|
||||
let err = settings.set(key, value).unwrap_err().to_string();
|
||||
assert!(!err.contains(secret), "{err}");
|
||||
}
|
||||
|
||||
settings.set("agents.quota.claude", "off").unwrap();
|
||||
settings
|
||||
.set("agents.quota.claude.consent", "revoked")
|
||||
.unwrap();
|
||||
assert!(settings.agents.is_empty(), "a default entry is removed");
|
||||
|
||||
for index in 0..MAX_AGENT_QUOTA_ENTRIES {
|
||||
settings
|
||||
.set(&format!("agents.quota.agent-{index}"), "on")
|
||||
.unwrap();
|
||||
}
|
||||
let err = settings.set("agents.quota.one-more", "on").unwrap_err();
|
||||
assert!(err.to_string().contains("would exceed"), "{err}");
|
||||
settings.set("agents.quota.agent-0", "off").unwrap();
|
||||
settings.set("agents.quota.one-more", "on").unwrap();
|
||||
assert_eq!(settings.agents.quota.len(), MAX_AGENT_QUOTA_ENTRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_ocean_treatment_folds_into_the_underwater_theme() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
+49
-6
@@ -10,25 +10,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
|
||||
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
|
||||
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
|
||||
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
|
||||
- **Anthropic xhigh effort:** Choosing xhigh effort on an Anthropic model that documents it, such as Claude Fable 5.2 or Claude Opus 4.8, now sends xhigh instead of max, so the model uses and bills the level you picked.
|
||||
- **Public model list:** The public model list gains GPT-6.1 Terra, Kimi K3.1 Pro (and its high-speed variant), MiniMax M3.1 Flash and Mistral Large 4, each marked unreviewed.
|
||||
- **Model Studio Token Plan:** Token Plan users can now pick `qwen4-max-preview` on both the OpenAI and Anthropic dialects. It was added from the model table Alibaba publishes on its Token Plan pricing page and has not been exercised against the live endpoint. Alibaba publishes no context window, output limit, tool calling or thinking control for it, so Codewhale records image input only and sends it no thinking switch on either dialect. The Coding Plan does not list it.
|
||||
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
|
||||
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
|
||||
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
|
||||
- **Website docs:** documentation pages no longer stack a second "Codewhale documentation" header under the site navigation; the line saying which release the docs describe now sits under the breadcrumb.
|
||||
- **Website languages:** the Roadmap and Contribute pages now show their titles, introductions and actions in all 18 site languages instead of English outside English and Chinese. The translations have had no native review yet.
|
||||
- **Responses dialect profiles:** Codex, DeepSeek and Concentrate Responses behavior now comes from one checked dialect table instead of per-provider code, a custom `wire = "responses"` provider can set `responses_profile = "standard"` (the default when omitted), and preview shows the profile each Responses request used. A Responses client whose route cannot be resolved now fails when it is created rather than at its first request ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)). Thanks [@SamhandsomeLee](https://github.com/SamhandsomeLee).
|
||||
- **Read-only builtins over the Runtime API:** `POST /v1/threads/{id}/commands/builtin` runs `/system`, `/tokens` or `/cost` against a thread and returns the same answer the terminal prints, including the effective system prompt the engine sends. Any other command name answers 404, a thread whose engine is not loaded or has no live context estimate answers 409, and the route never starts an engine or writes settings ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)). Verified by the app-server route tests and terminal-host tests; no editor client uses it yet. Thanks [@gaord](https://github.com/gaord).
|
||||
- **Automations:** a run missed while Codewhale was closed now waits on reopen for Run now or Skip instead of catching up silently; each automation can choose "If missed: ask" (default), "run once" or "skip". Tested in the engine only; no HTTP route tests, no desktop client yet, and a Mac asleep longer than the grace window counts as closed.
|
||||
- **Website maintainer:** a scheduled `web-maintainer` workflow proposes website upkeep as pull requests only: regenerated facts, and three DeepSeek lanes (stale version mentions, moved links, docs drift) that may edit only allowlisted copy files and must pass `npm run check` and the web tests. It is off until the repository variable `WEB_MAINTAINER_ENABLED` is set and a capped `WEB_MAINTAINER_DEEPSEEK_API_KEY` secret exists, and it has never run. The Worker's triage, PR-review, stale, duplicate and curate (Today's Dispatch drafting) cron tasks are removed; the digest stays.
|
||||
- **Website measurement:** the first-party ingest now accepts a closed website funnel schema while preserving existing runtime and browser batches. Reader confirmations remain self-reports, new website data stays first-party, and no deployment or live conversion gain is established by the source change.
|
||||
- **Website facts:** the website now reads the installed version, asset list and repository numbers from the published release and a dated snapshot instead of the source tree, shows nothing rather than 0 when a live number cannot be read, stamps its facts revision and date together, no longer calls the desktop app native, says when the release was read, and drops the price from its structured data. This is a source change and is not deployed yet.
|
||||
- **Website surface status:** the status of the terminal, browser, desktop, cloud and accounts surfaces now comes from typed states with copy in every locale, the desktop download appears only for a signed, notarized release with a checksum, the cloud-invitation and plan switches are committed values that default to off, the partner strip expires at render time, and repository numbers show when they were read. This is a source change and is not deployed yet.
|
||||
- **Website capabilities:** the website now lists a capability as available only when the published release has it, labels features that exist only in source as not yet released, keeps held features (cloud computers beyond invite-only, other vendors' agents over ACP, and plans) hidden behind committed switches that default to off, and prints provider route, catalog model and tool counts as they are at the published release tag instead of on main. This is not deployed yet.
|
||||
- **Installer uninstall lines:** the installers now print the uninstall command, the update-mode sentence and the shell-profile marker sentence only when the Codewhale they just installed reports an `uninstall` command, and older releases such as a pinned v0.10.2 get the manual removal steps instead of a command that would be sent to the model as a prompt. (CW-8029) The installers reach people through the website, so this is not deployed yet.
|
||||
- **Installer PATH ending:** when `codewhale` is not on PATH, the installers' last lines are now the copy-paste `export PATH=...; hash -r` command and `codewhale --version`, and when it is on PATH the last line is `Next: codewhale --version`; the Computer Use notice moves above them and shows on macOS only. (CW-8030) The installers reach people through the website, so this is not deployed yet.
|
||||
- **Installer PATH setup:** when the install directory is not on PATH, the macOS and Linux installers now add Codewhale's marked three-line PATH block to your shell profile, print exactly which file and lines they added and the command that removes them, and skip this with `--no-modify-path` or `CODEWHALE_NO_MODIFY_PATH=1`; the Windows archive installer adds the directory to your user PATH the same way and skips it with `-NoModifyPath`. Release archives carry this from 0.10.3 on, and the Windows installer has not been run yet. (CW-8036) The website serves install.sh, so this reaches people only when the website is next deployed.
|
||||
|
||||
- **Website and docs:** installation now leads to a version check and a verifiable first task, article search finds text and commands with section links, and GitHub star links show the fetched count. The hero reuses the generated drone whale, and control/tool references distinguish the unpublished source candidate from v0.10.2; these website changes still await deployment.
|
||||
- **External agent options:** `GET /v1/agents/external/{id}/options?cwd=` returns the models, modes and options an external agent itself offers for a folder, and which Codewhale actions it will not carry. A saved agent model the agent no longer offers is refused with a stable `choice_not_offered` code instead of being ignored, per-turn settings an agent cannot honor are refused as `unsupported_control`, and each agent turn records the context window and tokens the agent reported, with cost shown as unknown (CW-7879). Probing starts a real agent session in the folder, which the agent may keep in its own history. Mode and config choices are listed but cannot yet be chosen through thread creation. Verified on Linux by unit and local HTTP tests against a fixture agent; no real installed agent, macOS, Windows or hosted CI run.
|
||||
- **External agent interrupts:** Interrupting a turn while an external agent waits for approval now answers the agent's permission request as cancelled. (CW-7958)
|
||||
- **External agent quota:** The Runtime API route `GET /v1/agents/external/quota` reports each external agent's quota as unknown with a reason (flag off, consent missing, not implemented). The new `agents.quota.<agent>` and `agents.quota.<agent>.consent` settings, saved through `POST /v1/config`, record the opt-in and consent (at most 32 agent entries). No agent credential is read. (CW-7993)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Website design:** codewhale.net now uses the desktop app's 5.3 light and dark colour roles and Geist / Geist Mono, replacing the warm-paper palette and Shannon Sans; the vendored design copy is synced to 5.3.0 and the whale-road ocean bands are unchanged.
|
||||
|
||||
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Plugins, DSH and MCP tools are not yet attached to ACP-driven sessions. A real-editor journey is not yet proved.
|
||||
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
|
||||
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
|
||||
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
|
||||
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
|
||||
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
|
||||
- **Website copy:** The site's headline is now "The model can be anyone's. The agent should be yours.", with its subhead, belief and labelled product map in every language.
|
||||
- **Website routes:** Pages that repeated each other now redirect to the one page that does the job, the header and footer list only pages that exist under their current names, the sitemap drops redirected and catalogue pages, and machine-drafted translations say so.
|
||||
- **Website privacy:** The website no longer counts anything when your browser sends Global Privacy Control or Do Not Track, and the privacy page says exactly what is counted and how it is keyed.
|
||||
- Plugin UI: the Runtime API now serves reviewed DSH client closures on six seats (customize.plugins.detail, conversation.composer.dock, conversation.chat.node, workpane.panel, sidebar.section, command.row) with coded refusals, drops a plugin's UI grants the moment it is disabled, revoked or uninstalled, and adds a plugin route bridge that refuses until a plugin web server exists. No real DSH package has been served end to end yet, and a client that imports `@deepseek-ai/dsh-client-*` modules is still refused. (CW-7956)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
|
||||
- After choosing "explore offline" on first run, connecting a provider or Custom endpoint with /provider now takes effect immediately; messages are no longer held as "Saved for later" until restart, and a message held while offline returns to the composer.
|
||||
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
|
||||
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
|
||||
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
|
||||
- The Runtime API's `POST /v1/git/push` now pushes exactly one branch at one commit (`<commit>:refs/heads/<branch>`, no follow-tags or submodule recursion), so `push.default`, `remote.<name>.push` and `push.followTags` can no longer publish other branches or tags. Clients can bind a push to a named branch and expected HEAD with `branch` + `expect.head` (advertised as `named_push` in `GET /v1/git`); a stale branch or HEAD answers `409 git_state_changed` and pushes nothing. A bare push from a detached HEAD or an unborn branch is now refused, and the desktop app has not yet adopted the named form. Verified on macOS by unit tests against a local bare remote; no real network remote, Linux, Windows or hosted CI run.
|
||||
- Cancelling or timing out a read-only Git tool (git_status, git_diff, git_commit_plan, git_log, git_show, git_blame, git_merge_tree) now stops the git process and anything it started, instead of leaving it running in the background. Verified on macOS by unit tests; Linux, Windows and hosted CI are not yet run, and the filter-override probe is still not contained.
|
||||
- TUI: the launch resume confirmation no longer treats Meta+Enter, Meta+Y or Hyper+Enter as "resume"; only a plain Enter/Y or Shift+Y answers it. Verified by the focused unit tests only; not tried in a terminal that sends Meta or Hyper.
|
||||
- TUI: mentioning a huge directory or a far-away line range (`@file:N-M`) now stops after a bounded scan and says the result is partial instead of claiming end of file. Verified by the focused `file_mention` unit tests; no real 64 MiB file was tried.
|
||||
- Runtime `patch-undo` now restores a turn's own file changes in the default (Ask) posture without trust mode or Full Access; later edits and changes the turn did not make are still refused with nothing changed, and the `restore_requires_trust` error code is gone ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)). Verified by app-server and TUI unit tests on Linux; no Windows `/undo` or process-restart run.
|
||||
- **Website terminal frames:** The site can render terminal frames captured from the released version, and chooses each frame's caption from where it was captured.
|
||||
- **Website docs truth:** Documentation pages now describe the released version first and show what exists only in source in a labelled section that switches on when a release carries it.
|
||||
- **Website Arabic pages:** On Arabic pages, English text that has no translation yet keeps its own direction and language, so punctuation, commands and dates no longer appear reversed.
|
||||
- A DSH plugin whose package includes a `.gitignore` at its root now installs instead of being refused as a hidden file; nested `.gitignore` files, other hidden files, credential files and links are still refused. (CW-8023)
|
||||
- `install.bat` in the Windows release archive now passes its arguments to `install.ps1`, so `install.bat -NoModifyPath` declines the user PATH edit like the PowerShell installer does, and the install guide says so. It has not been run on Windows. The batch file reaches people in the next release archive, and the guide sentence when the website is next deployed, because the site serves the guide but not `install.bat`. (CW-8046)
|
||||
|
||||
## [0.10.3] - 2026-11-08
|
||||
|
||||
@@ -37,11 +78,12 @@ until the matching tag, packages, checksums, and release assets exist.
|
||||
|
||||
### Work in progress and known limits
|
||||
|
||||
This source candidate is not the final 0.10.3 tag. The 2026-11-08 scope
|
||||
decision keeps App Server v2, the coarse Engine split and DSH all-33
|
||||
compatibility in 0.10.3; the final tag waits for that full list. The near-term
|
||||
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
|
||||
tag. Items moved to 0.10.4 by name are marked below.
|
||||
This source candidate is not the final 0.10.3 tag. There is no
|
||||
`0.10.3-beta.2`: the next release is the full 0.10.3, and its final tag waits
|
||||
for the full scope list. Codex App Server v2 is paused and is not part of
|
||||
0.10.3; the legacy JSON-RPC dialect is not retired in this release. The coarse
|
||||
Engine split and DSH all-33 compatibility stay in 0.10.3, and none of this
|
||||
work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
|
||||
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
|
||||
terminal sessions and background jobs owned by that Runtime thread. The
|
||||
@@ -584,7 +626,7 @@ tag. Items moved to 0.10.4 by name are marked below.
|
||||
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
|
||||
- **A failed custom-provider switch leaves the provider you were on.** When saving a custom provider succeeds but switching to it fails (its identity cannot be resolved, or the engine refuses the route), the next request keeps going to the provider the footer shows instead of the half-configured one.
|
||||
- **A zero page size over stdio still advances.** `thread/loaded/list` on the App Server's stdio transport treats a requested limit of `0` as `1`, so a client that sends `0` reaches the next page. `GET /v1/threads` is unchanged.
|
||||
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
|
||||
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did, including when such a tool is forced through an object-form `tool_choice`. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)). Thanks [@ychu824](https://github.com/ychu824) for the report.
|
||||
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
|
||||
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
|
||||
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
|
||||
@@ -1056,6 +1098,7 @@ tag. Items moved to 0.10.4 by name are marked below.
|
||||
- **[@Statter](https://github.com/Statter)** — reported black-on-black text on terminals that do not report their background ([#7043](https://github.com/codewhale-hq/Codewhale/issues/7043)).
|
||||
- **[@eugenicum](https://github.com/eugenicum)** — asked for a visible Full Access flag for `serve --acp` ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992)).
|
||||
- **[@luestr](https://github.com/luestr)** — reported file and conversation rollback diverging ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)), the Linux multiline send key ([#7093](https://github.com/codewhale-hq/Codewhale/issues/7093)) and session export guidance ([#6996](https://github.com/codewhale-hq/Codewhale/issues/6996)).
|
||||
- **[@ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
|
||||
|
||||
## [0.10.2] - 2026-10-22
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Remote built-in command execution over the engine's host snapshot.
|
||||
//!
|
||||
//! The app-server assembles a [`BuiltinCommandHostSnapshot`] from thread
|
||||
//! state and calls [`execute_builtin`] through the installed catalog. This
|
||||
//! module is the terminal host's side: it maps the snapshot into the command
|
||||
//! contract's facet envelope and runs the portable debug handlers.
|
||||
//!
|
||||
//! Phase 1 serves exactly the read-only diagnostics commands whose every
|
||||
//! data path the snapshot backs faithfully (`/system`, `/tokens`, `/cost`).
|
||||
//! Two gates keep the surface honest: the [`SERVED`] allowlist, and the
|
||||
//! handler-declared capability mask checked against [`PROVIDED`]. Commands
|
||||
//! outside both are refused by returning `None`, which the API renders as
|
||||
//! an unknown command rather than a degraded answer.
|
||||
|
||||
use codewhale_command_contract::facets::{
|
||||
CommandDebugDiagnosticsContext, CommandPresentationContext, DebugBalanceProjection,
|
||||
DebugCacheCall, DebugCacheInspectionUnavailable, DebugCacheRates, DebugCacheTelemetry,
|
||||
DebugCostProjection, DebugPromptContext, DebugPromptSourceMap, DebugSystemProjection,
|
||||
DebugTokenProjection, DebugToolSnapshot,
|
||||
};
|
||||
use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler};
|
||||
use codewhale_engine::command_catalog::{BuiltinCommandExecution, BuiltinCommandHostSnapshot};
|
||||
use codewhale_localization::{Locale, tr};
|
||||
|
||||
use crate::commands::contract::{apply_named_replacements, resolve_message_id};
|
||||
use codewhale_commands::debug as portable_debug;
|
||||
|
||||
/// Facets this remote host can honestly provide from the snapshot.
|
||||
const PROVIDED: CommandCapabilities =
|
||||
CommandCapabilities::DEBUG_DIAGNOSTICS.union(CommandCapabilities::PRESENTATION);
|
||||
|
||||
/// The phase-1 served set: read-only diagnostics whose every projection the
|
||||
/// snapshot carries. Widening this list requires widening the snapshot with
|
||||
/// faithfully assembled data first — never an adapter that guesses.
|
||||
const SERVED: [&str; 3] = ["system", "tokens", "cost"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Facet adapters over the snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SnapshotDiagnostics<'a> {
|
||||
snapshot: &'a BuiltinCommandHostSnapshot,
|
||||
}
|
||||
|
||||
impl CommandDebugDiagnosticsContext for SnapshotDiagnostics<'_> {
|
||||
/// Never served remotely: the provider-balance probe is a host action,
|
||||
/// and the allowlist keeps `/balance` off this surface. The projection
|
||||
/// states what the snapshot genuinely knows: nothing.
|
||||
fn balance_projection(&self) -> DebugBalanceProjection {
|
||||
DebugBalanceProjection {
|
||||
provider_display_name: "unavailable".to_string(),
|
||||
supports_balance_api: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn system_projection(&self) -> DebugSystemProjection {
|
||||
DebugSystemProjection {
|
||||
mode_label: self.snapshot.mode_label.clone(),
|
||||
prompt: self.snapshot.system_prompt.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn token_projection(&self) -> DebugTokenProjection {
|
||||
self.snapshot.tokens.clone()
|
||||
}
|
||||
|
||||
fn cost_projection(&self) -> DebugCostProjection {
|
||||
self.snapshot.tokens.cost.clone()
|
||||
}
|
||||
|
||||
/// Assembled prompt-layer telemetry is a terminal-host report; the
|
||||
/// remote surface does not serve `/context`, so this stays an explicitly
|
||||
/// empty map rather than a partial reconstruction.
|
||||
fn context_source_map(&self) -> DebugPromptSourceMap {
|
||||
DebugPromptSourceMap {
|
||||
entries: Vec::new(),
|
||||
total_estimated_tokens: 0,
|
||||
active_context_estimated_tokens: 0,
|
||||
overflow_guard_estimated_tokens: None,
|
||||
context_window_tokens: None,
|
||||
context_window_source: None,
|
||||
budget_used_percent: None,
|
||||
pressure_label: String::new(),
|
||||
context_window_verified: false,
|
||||
generated_at: String::new(),
|
||||
note: "not assembled over the remote command surface".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_context(&self) -> DebugPromptContext {
|
||||
DebugPromptContext {
|
||||
schema_version: 0,
|
||||
provider: String::new(),
|
||||
model: self.snapshot.model.clone(),
|
||||
system_prompt_state: String::new(),
|
||||
tool_catalog_state: String::new(),
|
||||
sections: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
source_map: self.context_source_map(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_snapshot(&self) -> Option<DebugToolSnapshot> {
|
||||
None
|
||||
}
|
||||
|
||||
fn inspect_cache(
|
||||
&self,
|
||||
) -> Result<
|
||||
codewhale_command_contract::DebugCacheInspectionObservation,
|
||||
DebugCacheInspectionUnavailable,
|
||||
> {
|
||||
Err(DebugCacheInspectionUnavailable::NoConcreteRoute)
|
||||
}
|
||||
|
||||
fn remember_cache_inspection(
|
||||
&mut self,
|
||||
_inspection: codewhale_command_contract::facets::DebugPromptInspection,
|
||||
) {
|
||||
// Read-only surface: nothing to remember between remote executions.
|
||||
}
|
||||
|
||||
/// The remote surface does not serve `/cache`; the honest encoding of
|
||||
/// "not assembled" is absent telemetry, never zeros dressed as data.
|
||||
fn cache_telemetry(&self) -> DebugCacheTelemetry {
|
||||
DebugCacheTelemetry {
|
||||
model: self.snapshot.model.clone(),
|
||||
session_cache_rates: DebugCacheRates::default(),
|
||||
history: Vec::new(),
|
||||
calls: Vec::<DebugCacheCall>::new(),
|
||||
calls_total: 0,
|
||||
calls_currency: self.snapshot.tokens.cost.currency,
|
||||
calls_legacy_coverage_unknown: false,
|
||||
history_capacity: 0,
|
||||
prefix_stability_pct: None,
|
||||
prefix_checks_total: 0,
|
||||
prefix_change_count: 0,
|
||||
prefix_drift_count: 0,
|
||||
prefix_context_updates: 0,
|
||||
prefix_pin_reason: None,
|
||||
prefix_last_miss_reason: None,
|
||||
last_prefix_change_desc: None,
|
||||
last_pinned_prefix_hash: None,
|
||||
api_message_count: self.snapshot.tokens.api_message_count,
|
||||
non_system_message_count: self.snapshot.tokens.chat_message_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SnapshotPresentation {
|
||||
locale: Locale,
|
||||
}
|
||||
|
||||
impl CommandPresentationContext for SnapshotPresentation {
|
||||
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
|
||||
let message_id = resolve_message_id(key).ok_or("unknown translation key")?;
|
||||
let template = tr(self.locale, message_id);
|
||||
apply_named_replacements(&template, replacements)
|
||||
.ok_or_else(|| "invalid translation replacement contract".to_string())
|
||||
}
|
||||
|
||||
fn language_name(&self) -> &'static str {
|
||||
self.locale.translation_target_name()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Execute one built-in command against the snapshot, or `None` when the
|
||||
/// command is outside the remote surface.
|
||||
pub fn execute_builtin(
|
||||
name: &str,
|
||||
args: Option<&str>,
|
||||
snapshot: &BuiltinCommandHostSnapshot,
|
||||
) -> Option<BuiltinCommandExecution> {
|
||||
let (info, handler) = resolve(name)?;
|
||||
let CommandHandler::Contextual {
|
||||
capabilities,
|
||||
handler: run,
|
||||
} = handler
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// The capability mask is the second gate: a handler that grew a new
|
||||
// facet requirement is refused until the snapshot grows the data for it.
|
||||
if !PROVIDED.contains(capabilities) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut diagnostics = SnapshotDiagnostics { snapshot };
|
||||
let mut presentation = SnapshotPresentation {
|
||||
locale: snapshot.locale,
|
||||
};
|
||||
let contexts = CommandContexts::empty()
|
||||
.with_debug_diagnostics(&mut diagnostics)
|
||||
.with_presentation(&mut presentation);
|
||||
let result: portable_debug::CommandResult = run(contexts, args);
|
||||
Some(BuiltinCommandExecution {
|
||||
name: info.name.to_string(),
|
||||
text: result.message.unwrap_or_default(),
|
||||
is_error: result.is_error,
|
||||
text_only: result.action.is_none(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `name` is on the remote surface at all. Shares [`resolve`] with
|
||||
/// [`execute_builtin`], so the two gates can never disagree about a name.
|
||||
pub fn serves_builtin(name: &str) -> bool {
|
||||
resolve(name).is_some()
|
||||
}
|
||||
|
||||
/// The portable handler a raised spelling (canonical name or alias) names,
|
||||
/// gated by the [`SERVED`] allowlist and the [`PROVIDED`] capability mask.
|
||||
fn resolve(
|
||||
name: &str,
|
||||
) -> Option<(
|
||||
&'static codewhale_command_contract::metadata::CommandInfo,
|
||||
CommandHandler<portable_debug::CommandResult>,
|
||||
)> {
|
||||
let spelling = name.trim_start_matches('/').to_ascii_lowercase();
|
||||
let (info, handler) = portable_debug::portable_handlers()
|
||||
.into_iter()
|
||||
.find(|(info, _)| {
|
||||
info.name == spelling.as_str() || info.aliases.contains(&spelling.as_str())
|
||||
})?;
|
||||
// The allowlist gates on the canonical name, so an alias resolves to the
|
||||
// same served command its canonical spelling is.
|
||||
if !SERVED.contains(&info.name) {
|
||||
return None;
|
||||
}
|
||||
// A non-contextual handler asks nothing of the host, so it has nothing to
|
||||
// read and is refused here rather than served an empty envelope.
|
||||
if !matches!(handler, CommandHandler::Contextual { capabilities, .. } if PROVIDED.contains(capabilities))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((info, handler))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_command_contract::facets::DebugSystemPrompt;
|
||||
use codewhale_command_contract::types::{CommandCurrency, CommandMode};
|
||||
|
||||
fn snapshot() -> BuiltinCommandHostSnapshot {
|
||||
BuiltinCommandHostSnapshot {
|
||||
mode: CommandMode::Agent,
|
||||
mode_label: "WORK".to_string(),
|
||||
locale: Locale::En,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
system_prompt: DebugSystemPrompt::Text("You are helpful.".to_string()),
|
||||
tokens: DebugTokenProjection {
|
||||
active_context_used: 4_200,
|
||||
context_window: 131_072,
|
||||
last_input: Some(3_900),
|
||||
last_output: Some(312),
|
||||
cache_hit: Some(3_400),
|
||||
cache_miss: Some(500),
|
||||
total_tokens: 21_000,
|
||||
cache_write_tokens: 640,
|
||||
api_message_count: 12,
|
||||
chat_message_count: 8,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
cost: DebugCostProjection {
|
||||
currency: CommandCurrency::Usd,
|
||||
total: 0.012_5,
|
||||
parent_turns: 0.012,
|
||||
subagents: 0.000_5,
|
||||
display_floor: 0.0,
|
||||
priced_turns: 6,
|
||||
unpriced_turns: 0,
|
||||
legacy_coverage_unknown: false,
|
||||
user_declared_estimates: false,
|
||||
itemized_turns: 6,
|
||||
route_amounts: Vec::new(),
|
||||
turn_history_capacity: 40,
|
||||
unpriced_reason_labels: Vec::new(),
|
||||
unpriced_classes: Vec::new(),
|
||||
pricing_provenances: Vec::new(),
|
||||
live_pricing_defects: Vec::new(),
|
||||
unusable_pricing_defects: Vec::new(),
|
||||
route_receipts: Vec::new(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_system_with_mode_label_and_prompt() {
|
||||
let execution = execute_builtin("system", None, &snapshot()).expect("served");
|
||||
assert_eq!(execution.name, "system");
|
||||
assert!(!execution.is_error);
|
||||
assert!(execution.text_only);
|
||||
assert!(execution.text.contains("WORK"));
|
||||
assert!(execution.text.contains("You are helpful."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_tokens_with_published_usage() {
|
||||
let execution = execute_builtin("/tokens", None, &snapshot()).expect("served");
|
||||
assert_eq!(execution.name, "tokens");
|
||||
assert!(execution.text.contains("21,000") || execution.text.contains("21000"));
|
||||
assert!(execution.text.contains("deepseek-v4-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_cost_with_the_priced_subtotal() {
|
||||
let execution = execute_builtin("cost", None, &snapshot()).expect("served");
|
||||
assert!(!execution.is_error);
|
||||
assert!(!execution.text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_engine_aliases_to_the_canonical_command() {
|
||||
let execution = execute_builtin("xitong", None, &snapshot()).expect("served");
|
||||
assert_eq!(execution.name, "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_served_gate_agrees_with_execution_for_every_spelling() {
|
||||
// The route checks `serves_builtin` before assembling any thread
|
||||
// state, then calls `execute_builtin`: a name the first gate admits
|
||||
// must never be refused by the second, and vice versa.
|
||||
let snapshot = snapshot();
|
||||
let mut spellings: Vec<String> = SERVED.iter().map(|name| (*name).to_string()).collect();
|
||||
for (info, _) in portable_debug::portable_handlers() {
|
||||
spellings.push(info.name.to_string());
|
||||
spellings.extend(info.aliases.iter().map(|alias| (*alias).to_string()));
|
||||
}
|
||||
spellings.push("nope".to_string());
|
||||
for name in spellings {
|
||||
for raised in [name.clone(), format!("/{name}"), name.to_uppercase()] {
|
||||
let served = serves_builtin(&raised);
|
||||
let executed = execute_builtin(&raised, None, &snapshot).is_some();
|
||||
assert_eq!(served, executed, "{raised}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_commands_outside_the_phase1_allowlist() {
|
||||
// /balance is a host action (FetchBalance), /cache needs assembled
|
||||
// telemetry, /undo needs the undo facet: none are honestly served.
|
||||
for name in [
|
||||
"balance", "cache", "undo", "retry", "context", "tools", "nope",
|
||||
] {
|
||||
assert!(execute_builtin(name, None, &snapshot()).is_none(), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2456,17 +2456,23 @@ pub(crate) struct PresentationAdapter<'a> {
|
||||
host: SharedCommandHost<'a>,
|
||||
}
|
||||
|
||||
/// Resolve one stable key to the current catalog id, in the order every host
|
||||
/// adapter must use. Kept in one place so the terminal host and the remote
|
||||
/// snapshot-backed adapter cannot answer the same key differently.
|
||||
pub(crate) fn resolve_message_id(key: &str) -> Option<MessageId> {
|
||||
key_to_utility_message_id(key)
|
||||
.or_else(|| key_to_project_message_id(key))
|
||||
.or_else(|| key_to_plugin_message_id(key))
|
||||
.or_else(|| key_to_session_message_id(key))
|
||||
.or_else(|| diagnostics_messages::resolve(key))
|
||||
.or_else(|| config_policy_messages::resolve(key))
|
||||
.or_else(|| config_messages::resolve(key))
|
||||
.or_else(|| core_navigation::resolve_message(key))
|
||||
}
|
||||
|
||||
impl CommandPresentationContext for PresentationAdapter<'_> {
|
||||
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
|
||||
let Some(message_id) = key_to_utility_message_id(key)
|
||||
.or_else(|| key_to_project_message_id(key))
|
||||
.or_else(|| key_to_plugin_message_id(key))
|
||||
.or_else(|| key_to_session_message_id(key))
|
||||
.or_else(|| diagnostics_messages::resolve(key))
|
||||
.or_else(|| config_policy_messages::resolve(key))
|
||||
.or_else(|| config_messages::resolve(key))
|
||||
.or_else(|| core_navigation::resolve_message(key))
|
||||
else {
|
||||
let Some(message_id) = resolve_message_id(key) else {
|
||||
return Err("unknown translation key".to_string());
|
||||
};
|
||||
let locale = self.host.app.borrow().ui_locale;
|
||||
|
||||
Loaded 100 of 529 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user