diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 28c431d7b..18ff78b65 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -278,6 +278,15 @@ jobs: AGENT_TAG: ${{ env.DOCKERHUB_NS }}/agent:${{ steps.version.outputs.version }} run: ./scripts/verify-antigravity-image.sh + # Bundled Agent Tank mode runs the CLI inside this image, so the release + # has to prove it returns real usage - not just that the binary is there. + # Antigravity is the provider this runner is authenticated for. + - name: Verify authenticated bundled Agent Tank usage from packaged agent image + env: + AGENT_TAG: ${{ env.DOCKERHUB_NS }}/agent:${{ steps.version.outputs.version }} + AGENT_TANK_PROVIDERS: agy + run: ./scripts/verify-agent-tank-image.sh + - name: Validate serialized SQLite startup with packaged images env: APP_TAG: ${{ env.DOCKERHUB_NS }}/app:${{ steps.version.outputs.version }} diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index e0384dca8..146479e37 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -553,6 +553,7 @@ jobs: scripts/smoke-test-images.sh \ scripts/smoke-test-preview-runtime-images.sh \ scripts/smoke-test-sqlite-startup.sh \ + scripts/verify-agent-tank-image.sh \ scripts/verify-antigravity-image.sh } > >(tee -a build_log.txt) 2>&1 diff --git a/Dockerfile.agent b/Dockerfile.agent index 113a59899..895dc3061 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -154,6 +154,21 @@ RUN case "${VIBE_CLI_VERSION}" in \ && if [ ! -e /usr/local/bin/vibe-acp ]; then ln -s /usr/local/bin/vibe /usr/local/bin/vibe-acp; fi +FROM agent-base AS agent-tank-cli +# Pinned here, not in CI: this literal is part of the Dockerfile content that +# feeds the agent bundle content hash, so bumping it produces a new image tag. +# An env-only override in CI would NOT change the tag and would silently ship a +# different binary under an existing tag - do not do that. +ARG AGENT_TANK_CLI_VERSION=0.9.11 +USER root +# node-pty compiles against the toolchain already present in agent-base +# (build-essential + python3), which is why this needs no extra apt packages. +RUN npm install -g "agent-tank@${AGENT_TANK_CLI_VERSION}" \ + && npm cache clean --force \ + && rm -rf /root/.npm \ + && agent-tank --version + + FROM agent-base AS antigravity-cli ARG ANTIGRAVITY_CLI_VERSION=1.2.4 ARG ANTIGRAVITY_CLI_RELEASE_ID=6085322963025920 @@ -196,18 +211,21 @@ ARG CODEX_CLI_VERSION=0.154.0 ARG ANTIGRAVITY_CLI_VERSION=1.2.4 ARG OPENCODE_CLI_VERSION=1.18.31 ARG VIBE_CLI_VERSION=2.25.4 +ARG AGENT_TANK_CLI_VERSION=0.9.11 LABEL dev.propr.agent-bundle="true" \ dev.propr.agent.claude.version="${CLAUDE_CLI_VERSION}" \ dev.propr.agent.codex.version="${CODEX_CLI_VERSION}" \ dev.propr.agent.antigravity.version="${ANTIGRAVITY_CLI_VERSION}" \ dev.propr.agent.opencode.version="${OPENCODE_CLI_VERSION}" \ - dev.propr.agent.vibe.version="${VIBE_CLI_VERSION}" + dev.propr.agent.vibe.version="${VIBE_CLI_VERSION}" \ + dev.propr.agent-tank.version="${AGENT_TANK_CLI_VERSION}" USER root COPY --from=claude-cli /usr/local/lib/node_modules/@anthropic-ai /usr/local/lib/node_modules/@anthropic-ai COPY --from=codex-cli /usr/local/lib/node_modules/@openai /usr/local/lib/node_modules/@openai COPY --from=opencode-cli /usr/local/lib/node_modules/opencode-ai /usr/local/lib/node_modules/opencode-ai +COPY --from=agent-tank-cli /usr/local/lib/node_modules/agent-tank /usr/local/lib/node_modules/agent-tank COPY --from=vibe-cli /usr/local/bin/uv /usr/local/bin/uv COPY --from=vibe-cli /usr/local/bin/uvx /usr/local/bin/uvx COPY --from=vibe-cli /usr/local/bin/vibe /usr/local/bin/vibe @@ -223,6 +241,7 @@ ENV UV_TOOL_DIR=/opt/uv/tools \ COPY --chown=node:node \ scripts/agent-entrypoint.sh \ + scripts/agent-tank-runtime.mjs \ scripts/claude-entrypoint.sh \ scripts/codex-entrypoint.sh \ scripts/antigravity-entrypoint.sh \ @@ -241,6 +260,7 @@ RUN set -eu; \ link_npm_bin @anthropic-ai/claude-code claude \ && link_npm_bin @openai/codex codex \ && link_npm_bin opencode-ai opencode \ + && link_npm_bin agent-tank agent-tank \ && ln -sf /home/node/.local/bin/agy /usr/local/bin/agy \ && chmod +x \ /home/node/agent-entrypoint.sh \ @@ -255,7 +275,8 @@ RUN set -eu; \ && codex --version \ && opencode --version \ && vibe --version \ - && agy --version + && agy --version \ + && agent-tank --version USER node RUN git config --global user.name "ProPR Agent Bot" \ diff --git a/apps/desktop/scripts/packaged-connect-journey-fixture.mjs b/apps/desktop/scripts/packaged-connect-journey-fixture.mjs index 94bf57bf9..fc2e2e91b 100644 --- a/apps/desktop/scripts/packaged-connect-journey-fixture.mjs +++ b/apps/desktop/scripts/packaged-connect-journey-fixture.mjs @@ -213,8 +213,12 @@ export const createPackagedJourneyFixture = async ({ approvalReadinessDelayMs = return; } if (request.method === 'GET' && record.authorization === `Bearer ${token}`) { - response.writeHead(200, cors); - response.end('{}'); + // Optional dashboard reads can finish before the authenticated socket. + // Returning 200 with {} supplies invalid data that crashes the route and + // removes the selector before it can report REACT_CONNECTED. Let these + // unimplemented reads use the renderer's normal request-error handling. + response.writeHead(404, cors); + response.end('{"code":"UNIMPLEMENTED_SMOKE_ENDPOINT"}'); return; } } catch { diff --git a/apps/desktop/scripts/packaged-connect-journey.test.mjs b/apps/desktop/scripts/packaged-connect-journey.test.mjs index 544439c41..dd0d2db2f 100644 --- a/apps/desktop/scripts/packaged-connect-journey.test.mjs +++ b/apps/desktop/scripts/packaged-connect-journey.test.mjs @@ -17,7 +17,7 @@ const encryption = { const confirmationPath = '/api/auth/user?desktop_account_confirmation=1'; for (const delay of [0, 300]) { - test(`real credential service pairs and reprobes against Connect fixture (approval delay ${delay}ms)`, async () => { + test(`real credential service pairs, rejects unimplemented API reads, and reprobes against Connect fixture (approval delay ${delay}ms)`, async () => { const fixture = await createPackagedJourneyFixture({ approvalReadinessDelayMs: delay }); const directory = await mkdtemp(join(tmpdir(), 'propr-connect-account-test-')); const profiles = new ProfileStore(directory, encryption); @@ -43,6 +43,20 @@ for (const delay of [0, 300]) { confirmation.assertComplete(); assert.deepEqual((await profiles.list()).profiles[0].account, PACKAGED_CONNECT_ACCOUNT); assert.equal((await service.probe(profile)).status, 'ready'); + // The connected renderer reads these before its socket is necessarily ready. + // A successful {} is invalid dashboard data and can unmount the entire route, + // preventing REACT_CONNECTED even though authentication succeeded. + for (const path of [ + '/api/dashboard/active?repository=all', + '/api/dashboard/stats?repository=all&period=7d', + '/api/unimplemented-connect-smoke-endpoint', + ]) { + const response = await fetch(`${fixture.endpoint}${path}`, { + headers: { Authorization: `Bearer ${fixture.secrets[2]}` }, + }); + assert.equal(response.status, 404, path); + assert.deepEqual(await response.json(), { code: 'UNIMPLEMENTED_SMOKE_ENDPOINT' }); + } await service.dispose(); await profiles.close(); const reloaded = new ProfileStore(directory, encryption); diff --git a/docker/Dockerfile.app.prod b/docker/Dockerfile.app.prod index 79d8e3cd9..e8f11f1d5 100644 --- a/docker/Dockerfile.app.prod +++ b/docker/Dockerfile.app.prod @@ -92,6 +92,7 @@ COPY --from=builder /build/packages/api/package.json ./packages/api/ # through the mounted Docker socket, using /usr/src/app as the Docker context. COPY Dockerfile.agent ./Dockerfile.agent COPY scripts/agent-entrypoint.sh ./scripts/agent-entrypoint.sh +COPY scripts/agent-tank-runtime.mjs ./scripts/agent-tank-runtime.mjs COPY scripts/claude-entrypoint.sh ./scripts/claude-entrypoint.sh COPY scripts/codex-entrypoint.sh ./scripts/codex-entrypoint.sh COPY scripts/antigravity-entrypoint.sh ./scripts/antigravity-entrypoint.sh diff --git a/dockerhub/agent.md b/dockerhub/agent.md index a095cda24..2ff6cccf4 100644 --- a/dockerhub/agent.md +++ b/dockerhub/agent.md @@ -12,6 +12,11 @@ agent's credentials and task worktree. Version-specific bundle tags contain a complete CLI version matrix, so every agent instance can switch to the same image without another pull. +The image also bundles the [Agent Tank](https://github.com/integry/agent-tank) +CLI (`agent-tank`). ProPR's optional bundled usage-tracking mode runs it here +on demand, so operators do not have to install it — or a second copy of the +agent CLIs — on the host. It is inert unless that mode is enabled. + The common Debian runtime is an internal Dockerfile stage, not a separately published image. Custom installation-level packages create one derivative of the selected bundle. The base includes `build-essential` for native extension diff --git a/docs/ci-runners.md b/docs/ci-runners.md index 75cf39591..171800a91 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -106,7 +106,8 @@ expected contract, not a claim that the workers are configured or validated: `DOCKER_CONTEXT`, `DOCKER_TLS_VERIFY` and `DOCKER_CERT_PATH`. Job setup replaces HOME and Docker client config, so a saved HOME-based Docker context is not a reliable endpoint. `ci-rootless-preflight.sh` rejects default/remote/production - endpoints, checks the daemon reports rootless, and requires cgroup v2/systemd. + endpoints, checks the daemon reports rootless, requires cgroup v2/systemd, and + requires an init binary (the Redis helper starts `--init` containers). It does not prove socket ownership, host mount isolation or effective limits. - CI paths used as Docker bind sources must contain the same files at the same absolute path inside the runner and the daemon's host mount namespace. Map @@ -323,10 +324,25 @@ older attempts matching the exact owner; it preserves newer attempts and all other owners. An unexpected owner fails closed. Existing callers with no instance, including nightly, retain one container per job and attempt. +Each container runs with `--init`. The container's PID namespace reparents every +health-check process to PID 1 once its runc parent exits, and `redis-server` +does not reap them; one check every two seconds for the length of a shard +therefore filled `--pids-limit` with zombies and left a container the rootless +daemon could not kill, which failed the teardown step of a shard whose tests had +all passed. tini as PID 1 reaps them instead. + +Teardown (`stop`) is the only caller that tolerates a failed removal. It runs +after the tests have decided the job's result, and no step in the job can reap a +zombie PID, so a container whose ownership fully verifies but which the daemon +still refuses to remove is reported as a run warning and left for host cleanup. +Its state file is kept, so a later teardown of the same owner retries. Ownership +violations, and failed removals during `start`, still fail. + Regression tests prove `job=shard, instance=default` and `job=shard-default, instance=` coexist and either stop order preserves the other. They also cover foreign labels, tampered state, field-boundary -collisions, retries and resource limits using a Docker CLI double. +collisions, retries, resource limits, `--init` and the teardown tolerance using +a Docker CLI double. ## Coverage, required check and partial reruns diff --git a/docs/docs/features/observability.md b/docs/docs/features/observability.md index 5a54d9dbf..406a4ac3a 100644 --- a/docs/docs/features/observability.md +++ b/docs/docs/features/observability.md @@ -29,7 +29,7 @@ The task detail view exposes progress during execution, including streamed outpu ## Provider Capacity -With the optional [Agent Tank](../operations/agent-tank.md) integration enabled, provider capacity becomes a visible signal too: the sidebar shows live usage bars per subscription provider, and each LLM log entry records the usage delta its call consumed. Turn it on from the dashboard banner ProPR shows when it detects a running instance, from **Settings → LLM Usage Tracking**, or with `propr tank on`. +With the optional [Agent Tank](../operations/agent-tank.md) integration enabled, provider capacity becomes a visible signal too: the sidebar shows live usage bars per subscription provider, and each LLM log entry records the usage delta its call consumed. Turn it on from the dashboard banner, from **Settings → LLM Usage Tracking**, or with `propr tank bundled` — bundled mode runs Agent Tank inside the ProPR agent image, so there is nothing to install. ## Recovery diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md index 741e87d00..efbf0be5e 100644 --- a/docs/docs/features/propr-cli.md +++ b/docs/docs/features/propr-cli.md @@ -56,7 +56,7 @@ The full-screen wizard requires an interactive terminal. Over SSH or in shells w - `propr init stack [--root ]` creates `data/`, `logs/`, `repos/`, writes `.env` from the bundled template, and auto-detects agent credential directories on the host (`~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/opencode`, `~/.vibe`). - `propr check` reports the detected [GitHub auth mode](../operations/github-auth.md) (own App, relay, or demo) and flags missing or placeholder configuration before anything starts. `--verify` additionally runs an image/CLI smoke test per agent. - `propr start --no-tui` starts without the interactive dashboard (for scripts/CI); `--no-pull` skips image pulls; `--restart` recreates running services. -- `propr tank [on|off] [--url ]` toggles [Agent Tank](../operations/agent-tank.md) LLM usage tracking on a running stack (omit the state to print the current setting). +- `propr tank [bundled|external|off] [--url ]` configures [Agent Tank](../operations/agent-tank.md) LLM usage tracking on a running stack (omit the mode to print the current one). `bundled` runs Agent Tank inside the agent image with nothing to install; `external` needs `--url` pointing at a daemon you run. `on` remains a deprecated alias for `external`. ### Agent Skill diff --git a/docs/docs/operations/agent-tank.md b/docs/docs/operations/agent-tank.md index b9cda9126..1a8b1d979 100644 --- a/docs/docs/operations/agent-tank.md +++ b/docs/docs/operations/agent-tank.md @@ -1,10 +1,10 @@ # Agent Tank Usage Tracking -[Agent Tank](https://agenttank.io) is a separate, optional local tool that monitors the usage limits of your AI coding agent CLIs. ProPR integrates with it to show live provider capacity in the Web UI and to record per-call usage deltas alongside every LLM log entry. +[Agent Tank](https://agenttank.io) monitors the usage limits of your AI coding agent CLIs. ProPR integrates with it to show live provider capacity in the Web UI and to record per-call usage deltas alongside every LLM log entry. -Agent Tank is open source ([github.com/integry/agent-tank](https://github.com/integry/agent-tank)) and runs entirely on your own machine. It is **not** part of the ProPR stack — you install and run it yourself, then point ProPR at it. If you never enable it, ProPR works exactly the same; you just don't get the capacity bars. +Agent Tank is open source ([github.com/integry/agent-tank](https://github.com/integry/agent-tank)) and runs entirely on your own machine — nothing is sent anywhere. The integration is **off by default**; if you never turn it on, ProPR works exactly the same, you just don't get the capacity bars. -This page covers what Agent Tank is, how to run it, how to connect ProPR to it (including the Docker networking that makes the connection work), and what you see once it is enabled. +This page covers what Agent Tank tracks, the three integration modes, and what you see once it is enabled. ## What Agent Tank Tracks @@ -18,9 +18,11 @@ It is **not** an API-spend tracker. For pay-as-you-go API key billing or per-req | Codex (`codex`) | JSON-RPC `account/rateLimits/read`, falling back to `/status` | 5-hour session limit and weekly limit | | Antigravity (`agy`) | Runs the CLI's `/usage` command | Per-model quota availability and reset windows | +OpenCode and Vibe are not tracked: neither CLI exposes a subscription usage endpoint for Agent Tank to read. + ### How It Gets The Data -Agent Tank reads usage directly from the CLI tools you already have installed. It launches each CLI locally in a pseudo-terminal, runs the tool's built-in usage command, and parses the output into a unified dashboard and JSON API. Nothing leaves your machine. Specifically, it does **not**: +Agent Tank reads usage directly from the CLI tools you already have installed. It launches each CLI in a pseudo-terminal, runs the tool's built-in usage command, and parses the output. Nothing leaves your machine. Specifically, it does **not**: - scrape provider websites - read browser cookies or depend on a logged-in browser session @@ -30,18 +32,52 @@ Agent Tank reads usage directly from the CLI tools you already have installed. I This matters for ProPR: the usage numbers in the sidebar come from the same `/usage` output you would see if you ran the CLI yourself; no estimation is involved. -## Run Agent Tank +## The Three Integration Modes + +The integration is a single setting with three states. Choose it in **Settings → LLM Usage Tracking**, with `propr tank`, or via the `AGENT_TANK_MODE` environment variable. + +| Mode | What it does | When to use it | +|---|---|---| +| `disabled` | **Default.** Nothing is contacted or started. No usage tracking at all. | You don't want capacity bars. | +| `bundled` | ProPR runs the Agent Tank CLI **inside the `propr/agent` image** on demand, against your configured agent credentials. | Almost everyone. No host install, no daemon, no networking. | +| `external` | ProPR talks HTTP to an Agent Tank daemon **you** run yourself. | You already run Agent Tank, want its web dashboard, or want to track credentials ProPR doesn't manage. | + +Upgrades are transparent: an installation that had the integration enabled before bundled mode existed loads as `external` with exactly the URL it had, and a disabled one stays disabled. + +### Bundled Mode (Recommended) + +The `propr/agent` image already contains `claude`, `codex`, and `agy`, and ProPR already knows where each configured agent's credentials live. Bundled mode uses both: for each refresh it starts a short-lived container from the same agent image your tasks run in, mounts every enabled agent's credential directory **read-only** at the path that agent's runtime uses, and runs `agent-tank --once --json`. + +That means: + +- **Nothing to install.** No `npm install -g agent-tank`, no daemon to keep alive, no second copy of the agent CLIs. +- **No networking.** There is no HTTP endpoint and therefore no `localhost` vs `host.docker.internal` mistake to make. +- **Same credentials as your runs.** Bundled Agent Tank inspects exactly the directories the agents themselves use, so the numbers describe the accounts doing the work. The mounts are read-only, so a usage probe can never modify or corrupt them. +- **Private provider state.** Claude and Codex receive only a copy of their authentication file in a private, writable container directory. Codex can initialize its SQLite state there; Claude uses Agent Tank’s direct usage API. Host sessions, databases, plugins, and MCP configuration are not copied. Runtime state and credential copies disappear when the refresh container is removed. AGY continues reading its existing read-only mount. +- **A cached snapshot, not a live daemon.** Starting a container and driving `/usage` through a pseudo-terminal takes time, so ProPR caches the result and refreshes out of band. Per-call tracking reuses a fresh baseline or starts a refresh alongside the model call on a cold worker. After the call it awaits a new refresh, bounded by `AGENT_TANK_BUNDLED_TIMEOUT_MS`; the sidebar's refresh button also forces a fresh run. -Install and start it on the host that runs your agent CLIs (usually the same host as the ProPR stack): +Enable it with: + +```bash +propr tank bundled +``` + +Bundled mode reports the providers it can see. A provider Agent Tank does not support is left out, and so is an agent whose credential directory does not exist **on the Docker host** — the host daemon is what resolves the mount, so a directory that is simply not visible inside the ProPR backend container still counts. + +If two enabled agents share a provider — two Claude accounts, for example — one run can only inspect one of them, and the first enabled one wins. The snapshot then describes that account only: capacity-aware routing for the other alias reports "no usage data" rather than borrowing the inspected account's numbers. Use external mode if you need every account measured. + +### External Mode + +Use this when you run Agent Tank yourself. Install and start it on the host that runs your agent CLIs: ```bash npm install -g agent-tank # or run it directly with: npx agent-tank agent-tank # auto-discovers installed CLIs, serves dashboard + API ``` -By default it serves the dashboard and HTTP API at `http://127.0.0.1:3456` and, when Docker is available, also binds the Docker bridge gateway addresses so containers on the same host can reach it (see [Networking](#networking-propr-to-agent-tank) below). Building it compiles the native `node-pty` module, so the host needs Node.js 18+, Python 3.8+, and C/C++ build tools — see the [Agent Tank README](https://github.com/integry/agent-tank#installation-notes) if the build fails. +By default it serves the dashboard and HTTP API at `http://127.0.0.1:3456` and, when Docker is available, also binds the Docker bridge gateway addresses so containers on the same host can reach it (see [Networking](#networking-propr-to-an-external-agent-tank) below). Building it compiles the native `node-pty` module, so the host needs Node.js 18+, Python 3.8+, and C/C++ build tools — see the [Agent Tank README](https://github.com/integry/agent-tank#installation-notes) if the build fails. -You need at least one supported CLI installed, authenticated, and on the `PATH`. For Claude, `/usage` requires Claude Code 2.0+. +You need at least one supported CLI installed, authenticated, and on the `PATH` **of the host running Agent Tank**. In a normal ProPR install those CLIs live inside `propr/agent` rather than on the host, which is the friction bundled mode removes. Common flags: @@ -53,28 +89,13 @@ agent-tank --no-docker # bind localhost only (skip Docker bridge bindin agent-tank --claude-api # use the Anthropic OAuth usage API for Claude (faster refresh) ``` -To keep Agent Tank running alongside the ProPR stack, start it with `--background` (or run it under your own process manager). See the [Agent Tank README](https://github.com/integry/agent-tank) for the full option, environment-variable, and config-file reference. - -## Connect ProPR To Agent Tank - -There are three ways to turn the integration on. All three write the same backend setting (`enabled` plus a service `url`). - -**Detection banner (easiest).** When ProPR detects a running Agent Tank instance at the Docker-internal default (`http://host.docker.internal:3456`) and the integration is off, the dashboard and LLM Log page show a dismissible banner offering to enable it in one click. - -**Settings → LLM Usage Tracking.** Toggle *Enable Agent Tank Integration* and set the service URL. The section shows a live connectivity indicator (green "connected" / red with the error) so you can confirm ProPR can reach the service before relying on it. - -**CLI (`propr tank`).** Toggle it on a running stack from the terminal: +Then point ProPR at it: ```bash -propr tank # show the current setting (on/off + URL) -propr tank on # enable using the saved/default URL -propr tank on --url http://127.0.0.1:3456 # enable with a specific URL -propr tank off # disable +propr tank external --url http://host.docker.internal:3456 ``` -Because Agent Tank is an external service rather than a stack container, `propr tank` talks to the running ProPR backend — start the stack first (`propr start`). - -### Networking: ProPR To Agent Tank +#### Networking: ProPR To An External Agent Tank ProPR's shipped default URL is `http://0.0.0.0:3456`; the `propr tank` CLI client defaults to `http://127.0.0.1:3456`. The default only reaches Agent Tank when the ProPR backend runs directly on the host (a source checkout running `npm run daemon`/`npm run worker`). In the standard install the backend runs in Docker, where `0.0.0.0` and `localhost` resolve to the container itself — set the URL to `http://host.docker.internal:3456` there, which is exactly what the detection banner offers to do for you. @@ -85,9 +106,35 @@ Change the URL in two situations: Agent Tank's own bind addresses support the container case: by default it listens on `127.0.0.1` plus, when Docker is available, the **private** Docker bridge gateway addresses, so same-host containers can reach it without it being exposed on a public interface. `--no-docker` restricts it to localhost, which Docker containers cannot reach. -Two environment variables tune the backend integration: +This whole section is why bundled mode exists — none of it applies there. + +## Choosing The Mode + +There are three ways to set it. All write the same backend setting. + +**Detection banner (easiest).** While tracking is off, the dashboard and LLM Log page show a dismissible banner offering to turn it on in one click. If a daemon is already answering at `http://host.docker.internal:3456` the banner offers `external` pointed at it; otherwise it offers `bundled`. + +**Settings → LLM Usage Tracking.** Pick one of the three modes. The **Daemon URL** field only appears for `external`, because an external URL means nothing in the other two. The section shows a live status indicator so you can confirm the mode works before relying on it. -- `AGENT_TANK_URL` — fallback service URL used when no URL is saved in settings. +**CLI (`propr tank`).** Configure it on a running stack from the terminal: + +```bash +propr tank # show the current mode (plus URL, for external) +propr tank bundled # run Agent Tank inside the agent image +propr tank external --url http://127.0.0.1:3456 # use your own daemon +propr tank off # disable +``` + +`propr tank on` still works as a deprecated alias for `propr tank external` — that is what it has always meant — and prints a note saying so. + +Because this is a backend setting rather than a stack container, `propr tank` talks to the running ProPR backend — start the stack first (`propr start`). + +## Environment Variables + +- `AGENT_TANK_MODE` — `disabled`, `bundled`, or `external`. Only used when **no** setting has been saved yet, so headless deployments can configure the stack entirely from `.env`. A saved setting always wins. +- `AGENT_TANK_URL` — fallback service URL when none is saved. Applies to `external` mode only. +- `AGENT_TANK_BUNDLED_TIMEOUT_MS` — how long a bundled refresh container may run before it is abandoned (default `120000`). +- `AGENT_TANK_BUNDLED_CACHE_TTL_MS` — how long a bundled snapshot stays fresh before the next refresh starts a container (default `60000`). - `ANALYSIS_AGENT_TANK_TIMEOUT_MS` — per-request timeout for the pre/post-call usage probes (kept short so tracking never slows a task). ## What You See Once Enabled @@ -96,22 +143,27 @@ Two environment variables tune the backend integration: - **Per-call usage deltas.** Around each agent run ProPR snapshots usage before and after the call, computes the delta per metric, and stores it next to the [LLM Log](./metrics.md) entry. The task detail context strip shows a compact session/weekly delta chip for the run. - **Capacity in your metrics.** Provider capacity pressure becomes a first-class signal alongside cost and cycle time — see [Metrics](./metrics.md). +In bundled mode a baseline must be fresh when acquired near the start of the call; it does not expire while a long task runs. The post-call snapshot comes from a refresh started after the call finishes. A short call that finishes before its baseline is ready, a failed or timed-out refresh, or unchanged provider data produces no delta rather than a guessed measurement. + {/* SCREENSHOT PLACEHOLDER (P3 — needs a running Agent Tank instance; interim: the site's ui-agent-tank.png): Capture the sidebar Usage section with Agent Tank enabled, showing provider rows (for example Claude and Codex) with colored usage bars and percentages, and one provider expanded to show its session and weekly metrics. Requires a running Agent Tank instance configured in Settings. */} ## Best-Effort By Design -The integration never blocks a task. If Agent Tank is disabled, unreachable, or slow: +The integration never delays starting the model call. Returning its result may wait for the bounded post-call usage refresh. If Agent Tank is disabled, unreachable, slow, or — in bundled mode — the agent image is missing or the container fails: - the pre/post-call usage probes are skipped or time out quietly, - the LLM call runs and completes normally with no usage delta recorded, and - the sidebar Usage section hides itself. -So a missing or stopped Agent Tank instance degrades to "no capacity bars," and the work itself completes normally. +So a missing Agent Tank degrades to "no capacity bars," and the work itself completes normally. ## Troubleshooting -- **Sidebar is empty / "not connected" in Settings.** Confirm Agent Tank is running (`http://127.0.0.1:3456` in a browser) and that the URL ProPR uses is reachable *from inside the container* — typically `http://host.docker.internal:3456`, since `localhost` there resolves to the container itself. Avoid `--no-docker` when ProPR runs in Docker. -- **No agents found by Agent Tank.** At least one supported CLI (`claude`, `agy`, or `codex`) must be installed, authenticated, and on the `PATH` of the host running Agent Tank. Check with `claude --version` etc. -- **`Timeout waiting for usage data`.** Make sure the CLI works and is authenticated on its own (no pending trust/auth/update prompts). For Claude, try `--claude-api`. +- **Sidebar is empty in bundled mode.** Confirm the agent image is built and at least one enabled agent is authenticated. `docker run --rm propr/agent:latest agent-tank --version` proves the image ships the CLI; a task that runs successfully proves the credentials are mounted. From a source checkout, `scripts/verify-agent-tank-image.sh` proves the whole path end to end: it runs bundled mode against your own credentials (mounted read-only, no provider entrypoint) and fails unless every provider actually returns usage numbers. +- **"Bundled Agent Tank unavailable" right after selecting bundled.** The run worked but produced no usable usage, so there is nothing to monitor. Either it described no provider at all — bundled mode only inspects enabled `claude`, `codex` and `antigravity` agents, so an installation with only OpenCode or Vibe enabled lands here — or every provider it did describe returned an error (see `Timeout waiting for usage data` below). A provider key alone is not readiness: ProPR reports ready only once at least one provider's usage actually came back. +- **Sidebar is empty / "unreachable" in external mode.** Confirm Agent Tank is running (`http://127.0.0.1:3456` in a browser) and that the URL ProPR uses is reachable *from inside the container* — typically `http://host.docker.internal:3456`, since `localhost` there resolves to the container itself. Avoid `--no-docker` when ProPR runs in Docker. Bundled mode sidesteps all of this. +- **No agents found by Agent Tank.** At least one supported CLI (`claude`, `agy`, or `codex`) must be installed and authenticated. In bundled mode that means an enabled ProPR agent of that type whose credential directory exists on the Docker host; in external mode, a CLI on the `PATH` of the host running Agent Tank. +- **"This ProPR backend is too old to support bundled Agent Tank mode."** The UI or CLI is newer than the backend it is talking to. Upgrade the backend, or stay on external mode until you do — `disabled` and `external` keep working across that version gap. +- **`Timeout waiting for usage data`.** Make sure the CLI works and is authenticated on its own (no pending trust/auth/update prompts). For Claude, try `--claude-api` in external mode. For deeper operational context, see [Metrics](./metrics.md). diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md index 10b70e849..022a4116f 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -142,11 +142,14 @@ Optional: expose a local stack's API to the hosted control plane at `https://app ## Agent Tank & Metrics -These two variables are read from code but are not in `.env.example` — Agent Tank is normally connected through the Web UI or `propr agent-tank`, which save the URL as a backend setting. See [Agent Tank](./agent-tank.md). +These variables are read from code but are not in `.env.example` — Agent Tank is normally configured through the Web UI or `propr tank`, which save the mode as a backend setting. The integration has three modes (`disabled`, `bundled`, `external`); see [Agent Tank](./agent-tank.md). | Variable | Default (shipped / code) | What it does | Required when | |---|---|---|---| -| `AGENT_TANK_URL` | Code falls back to `http://0.0.0.0:3456` when no saved setting exists | Fallback Agent Tank service URL used when no URL is saved in settings. Empty or `false` disables usage tracking for LLM calls. | Only when configuring Agent Tank via env instead of the UI/CLI. | +| `AGENT_TANK_MODE` | `disabled` | Integration mode used when **no** Agent Tank setting has been saved yet: `disabled`, `bundled` (run the CLI inside the agent image), or `external` (talk HTTP to your own daemon). A saved setting always wins, and an unrecognized value is treated as `disabled`. | Only when configuring Agent Tank via env instead of the UI/CLI. | +| `AGENT_TANK_URL` | Code falls back to `http://0.0.0.0:3456` when no saved setting exists | Fallback Agent Tank service URL used when no URL is saved in settings. **Applies to `external` mode only** — bundled mode contacts no URL. Empty or `false` disables usage tracking for LLM calls in external mode. | Only when configuring external Agent Tank via env instead of the UI/CLI. | +| `AGENT_TANK_BUNDLED_TIMEOUT_MS` | `120000` | How long a bundled-mode refresh container may run before it is abandoned. A timeout degrades to "no usage data"; it never fails a task. | Optional, `bundled` mode only. | +| `AGENT_TANK_BUNDLED_CACHE_TTL_MS` | `60000` | How long a bundled-mode usage snapshot stays fresh before the next refresh starts a container. Per-LLM-call probes only read this cache. | Optional, `bundled` mode only. | | `ANALYSIS_AGENT_TANK_TIMEOUT_MS` | `2000` | Timeout for the Agent Tank status fetch wrapped around each LLM call. | Optional. | ## Advanced diff --git a/docs/mcp-coverage.md b/docs/mcp-coverage.md index e1012cfe1..bee9c6e42 100644 --- a/docs/mcp-coverage.md +++ b/docs/mcp-coverage.md @@ -55,7 +55,7 @@ remain separate gates. | Direct agent configuration | `get_agent_configuration`, `create_agent_configuration`, `update_agent_configuration`, `remove_agent_configuration`; actual types/models, alias, enablement, model labels/reasoning, CLI versions; new agents start disabled for secure login | | Synthetic-agent composition | `create_synthetic_agent`, `update_synthetic_agent`, `remove_synthetic_agent`; pool models/members, strategy, priority and usage thresholds; existing reference/default guards | | Advanced indexing policy | `get_indexing_configuration`, `update_indexing_configuration`; primary/fallback alias:model, prompt, enablement and runtime cooldown state | -| Provider policy | `get_provider_policy`, `update_provider_policy`, `get_provider_status`, `get_provider_usage`, `refresh_provider_usage`, `detect_provider_service`; Agent Tank service origin and enablement, no credential entry | +| Provider policy | `get_provider_policy`, `update_provider_policy`, `get_provider_status`, `get_provider_usage`, `refresh_provider_usage`, `detect_provider_service`; Agent Tank integration mode (`disabled`/`bundled`/`external`) and, for external, the service origin; no credential entry | | Execution/review/context | `get_execution_settings`, `update_execution_settings`; worker concurrency, analysis/planner models, review model/prompt/context enablement/model/budget, reasoning and bounded ultrafix defaults | | Workflow labels and keywords | `get_`/`update_` tools for `followup_keywords`, `followup_ignore_keywords`, `primary_processing_labels`, `pr_label`, `ai_primary_tag` | | Runtime package configuration/build | `get_runtime_configuration`, `update_runtime_configuration` | diff --git a/package-lock.json b/package-lock.json index f4709fdf6..1438a5cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8609,9 +8609,9 @@ } }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" @@ -10717,9 +10717,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", "engines": { @@ -13916,9 +13916,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/packages/api/mcp/toolsConfiguration.ts b/packages/api/mcp/toolsConfiguration.ts index 8195adf63..837720695 100644 --- a/packages/api/mcp/toolsConfiguration.ts +++ b/packages/api/mcp/toolsConfiguration.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { loadAgents, loadSyntheticAgents, loadMonitoredReposRaw, AGENT_DEFAULTS, AGENT_TYPES } from '@propr/core'; -import { getManagedAgentConfigPath, isAgentLoginSupported, syntheticAgentConfigSchema, REASONING_LEVELS } from '@propr/shared'; +import { AGENT_TANK_MODES, getManagedAgentConfigPath, isAgentLoginSupported, syntheticAgentConfigSchema, REASONING_LEVELS } from '@propr/shared'; import type { createConfigRoutes } from '../routes/configRoutes.js'; import { configRevision } from '../routes/configRevision.js'; import { callWorkflow } from './adapter.js'; @@ -56,7 +56,23 @@ export function addConfigurationTools(tools: McpTool[], deps: ToolDeps, config: workflow(tools, { name: 'get_indexing_configuration', description: 'Read indexing model/fallback policy, prompt, cooldowns and degradation state.', scope: 'manage', permission: 'instance.manage_settings', readOnly: true, schema: z.object({}).strict() }, config.getSummarizationSettings, () => ({})); workflow(tools, { name: 'update_indexing_configuration', description: 'Replace indexing configuration with explicit primary/fallback alias:model and prompt. Existing validation and delayed reindex behavior apply.', scope: 'manage', permission: 'instance.manage_settings', schema: z.object({ ...mutationShape, enabled: z.boolean(), agent_alias: z.string().max(256), fallback_agent_alias: z.string().max(256), custom_prompt: z.string().max(65536) }).strict() }, config.postSummarizationSettings, args => ({ body: args })); workflow(tools, { name: 'get_provider_policy', description: 'Read the configured Agent Tank provider policy; does not return credentials.', scope: 'manage', permission: 'instance.manage_agents', readOnly: true, schema: z.object({}).strict() }, config.getAgentTankSettings, () => ({})); - workflow(tools, { name: 'update_provider_policy', description: 'Configure the existing Agent Tank provider service with a non-secret HTTP(S) base URL and explicit enabled state. Requires instance.manage_agents.', scope: 'manage', permission: 'instance.manage_agents', schema: z.object({ ...mutationShape, enabled: z.boolean(), url: z.url().max(2048).refine(value => { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash && url.pathname === '/'; }, 'Use an HTTP(S) origin without credentials, path, query or fragment') }).strict() }, config.postAgentTankSettings, args => ({ body: { enabled: args.enabled, url: args.url.replace(/\/$/, '') } })); + workflow(tools, { + name: 'update_provider_policy', + description: 'Configure Agent Tank usage tracking. "bundled" runs it inside the ProPR agent image and needs no url; "external" targets an operator-run instance at a non-secret HTTP(S) base URL; "disabled" turns it off. Requires instance.manage_agents.', + scope: 'manage', + permission: 'instance.manage_agents', + schema: z.object({ + ...mutationShape, + mode: z.enum(AGENT_TANK_MODES), + // Optional because it is meaningless outside external mode; the refine + // below makes it required exactly when it matters, so the tool cannot be + // called into an inconsistent state. + url: z.url().max(2048).refine(value => { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash && url.pathname === '/'; }, 'Use an HTTP(S) origin without credentials, path, query or fragment').optional(), + }).strict().refine( + args => args.mode !== 'external' || typeof args.url === 'string', + { message: 'url is required when mode is "external"' }, + ), + }, config.postAgentTankSettings, args => ({ body: { mode: args.mode, url: args.url?.replace(/\/$/, '') } })); for (const [name, handler] of [['get_provider_status', config.getAgentTankStatus], ['get_provider_usage', config.getAgentTankUsage], ['detect_provider_service', config.getAgentTankDetect]] as const) workflow(tools, { name, description: 'Read the existing configured Agent Tank provider service state.', scope: 'manage', permission: 'instance.manage_agents', readOnly: true, schema: z.object({}).strict() }, handler, () => ({})); workflow(tools, { name: 'refresh_provider_usage', description: 'Refresh usage from the existing configured Agent Tank service.', scope: 'manage', permission: 'instance.manage_agents', schema: z.object(mutationShape).strict() }, config.postAgentTankRefresh, () => ({})); for (const action of ['create', 'remove'] as const) tools.push({ name: `${action}_repository_configuration`, description: `${action} a repository configuration under instance administration and explicit repository grants. Missing consent returns browser continuation without changing configuration.`, scope: 'manage', permission: 'instance.manage_settings', schema: z.object({ ...mutationShape, repository: repositorySchema, ...(action === 'create' ? { baseBranch: idSchema, enabled: z.boolean().default(true), alias: idSchema.optional() } : {}) }).strict(), run: async ({ principal, args }) => { diff --git a/packages/api/routes/configRoutesAgentTank.ts b/packages/api/routes/configRoutesAgentTank.ts index 80eeee373..de274a775 100644 --- a/packages/api/routes/configRoutesAgentTank.ts +++ b/packages/api/routes/configRoutesAgentTank.ts @@ -1,10 +1,14 @@ import { Request, Response } from 'express'; import * as configManager from '@propr/core'; import { - normalizeAgentTankAgents, - observeAgentTankUsageSnapshot, - type AgentStatusResponse + canRunBundledAgentTank, + getAgentTankStatuses, + hasAgentTankStatuses, + hasUsableAgentTankStatuses, + refreshBundledStatuses } from '@propr/core'; +import { AGENT_TANK_MODES, isAgentTankMode, normalizeAgentTankMode } from '@propr/shared'; + /** * Tells every open tab that capacity may have moved. * @@ -35,8 +39,26 @@ export function createAgentTankRoutes() { async function postAgentTankSettings(req: Request, res: Response): Promise { try { - const { enabled, url } = req.body; - await configManager.saveAgentTankSettings({ enabled: !!enabled, url: url || 'http://0.0.0.0:3456' }); + const { mode, enabled, url } = req.body ?? {}; + // Accept the legacy `{ enabled }` body so older CLI builds and any + // in-flight clients keep working during a rolling upgrade. + if (mode !== undefined && !isAgentTankMode(mode)) { + res.status(400).json({ error: `mode must be one of: ${AGENT_TANK_MODES.join(', ')}` }); + return; + } + const resolvedMode = mode === undefined + ? (enabled === true ? 'external' : 'disabled') + : normalizeAgentTankMode(mode); + if (resolvedMode === 'external' && typeof url === 'string' && url.trim() === '') { + res.status(400).json({ error: 'url is required when mode is "external"' }); + return; + } + // Keep a hand-tuned external URL when the caller omits one (bundled mode + // has no URL to send), so switching modes back and forth is lossless. + const resolvedUrl = typeof url === 'string' && url.trim() + ? url.trim() + : (await configManager.loadAgentTankSettings()).url; + await configManager.saveAgentTankSettings({ mode: resolvedMode, url: resolvedUrl }); res.json({ success: true }); // Enabling, disabling or repointing the integration changes what every // open sidebar should be showing, and the sidebar no longer polls to @@ -51,23 +73,52 @@ export function createAgentTankRoutes() { async function getAgentTankStatus(_req: Request, res: Response): Promise { try { const settings = await configManager.loadAgentTankSettings(); - if (!settings.enabled) { + if (settings.mode === 'disabled') { res.json({ available: false, reason: 'disabled' }); return; } + if (settings.mode === 'bundled') { + // "Available" for bundled mode means "we can produce a snapshot that + // carries at least one provider's usage", which is exactly what a + // (cached) refresh answers. Reusing the same call keeps the status + // indicator honest instead of asserting health from image presence alone. + const agents = await refreshBundledStatuses(); + if (!agents) { + res.json({ available: false, mode: 'bundled', reason: 'bundled_run_failed' }); + return; + } + // A run that had nothing to inspect - only OpenCode/Vibe enabled, or no + // enabled agent at all - succeeds with an empty map and starts no + // container. That is a successful run, not usage tracking: announcing it + // as ready would promise a gauge that can never show a number. + if (!hasAgentTankStatuses(agents)) { + res.json({ available: false, mode: 'bundled', reason: 'no_supported_agents' }); + return; + } + // A provider Agent Tank failed to read still appears in the map, carrying + // its error and an empty usage object. Keys alone therefore prove only + // that a provider was configured, so readiness asks for usage that + // actually came back. + if (!hasUsableAgentTankStatuses(agents)) { + res.json({ available: false, mode: 'bundled', reason: 'no_usage_data' }); + return; + } + res.json({ available: true, mode: 'bundled' }); + return; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); try { const response = await fetch(`${settings.url}/status/claude`, { signal: controller.signal }); clearTimeout(timer); if (response.ok) { - res.json({ available: true }); + res.json({ available: true, mode: 'external' }); } else { - res.json({ available: false, reason: `HTTP ${response.status}` }); + res.json({ available: false, mode: 'external', reason: `HTTP ${response.status}` }); } } catch { clearTimeout(timer); - res.json({ available: false, reason: 'unreachable' }); + res.json({ available: false, mode: 'external', reason: 'unreachable' }); } } catch (error) { console.error('Error in /api/config/agent-tank/status GET:', error); @@ -78,31 +129,24 @@ export function createAgentTankRoutes() { async function getAgentTankUsage(_req: Request, res: Response): Promise { try { const settings = await configManager.loadAgentTankSettings(); - if (!settings.enabled) { + if (settings.mode === 'disabled') { res.json({ enabled: false }); return; } - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5000); - try { - const response = await fetch(`${settings.url}/status`, { signal: controller.signal }); - clearTimeout(timer); - if (response.ok) { - const data = await response.json() as Record; - const agents = normalizeAgentTankAgents(data); - // This is the read that supplies the client's usage snapshot, so it is - // also where a changed snapshot is observed: announced before the - // response, so a client woken by it cannot re-read older state. The - // observer seeds silently and says nothing about an unchanged read. - await observeAgentTankUsageSnapshot(agents); - res.json({ enabled: true, agents }); - } else { - res.json({ enabled: true, error: `HTTP ${response.status}` }); - } - } catch { - clearTimeout(timer); - res.json({ enabled: true, error: 'unreachable' }); + // One transport-agnostic call: the UI response shape is unchanged, so + // AgentTankSidebar needs no modification for bundled mode. + const agents = await getAgentTankStatuses(); + if (agents) { + // Observe the exact normalized snapshot before returning it, for either transport. + await configManager.observeAgentTankUsageSnapshot(agents); } + res.json(agents + ? { enabled: true, mode: settings.mode, agents } + : { + enabled: true, + mode: settings.mode, + error: settings.mode === 'bundled' ? 'bundled_run_failed' : 'unreachable' + }); } catch (error) { console.error('Error in /api/config/agent-tank/usage GET:', error); res.status(500).json({ error: 'Failed to fetch Agent Tank usage' }); @@ -112,10 +156,33 @@ export function createAgentTankRoutes() { async function postAgentTankRefresh(_req: Request, res: Response): Promise { try { const settings = await configManager.loadAgentTankSettings(); - if (!settings.enabled) { + if (settings.mode === 'disabled') { res.json({ success: false, error: 'Agent Tank not enabled' }); return; } + if (settings.mode === 'bundled') { + // `force` because this is an explicit operator action: they pressed + // refresh precisely because they do not trust the cached snapshot. + const agents = await refreshBundledStatuses({ force: true }); + // Same evidence rule as the status route: a snapshot with no usable + // provider usage - no provider at all, or only failed ones - did not + // refresh any usage data, so it cannot be reported as a successful + // refresh. + if (!agents) { + res.json({ success: false, error: 'bundled_run_failed' }); + return; + } + if (!hasAgentTankStatuses(agents)) { + res.json({ success: false, error: 'no_supported_agents' }); + return; + } + const refreshed = hasUsableAgentTankStatuses(agents); + res.json(refreshed + ? { success: true } + : { success: false, error: 'no_usage_data' }); + if (refreshed) publishUsageChanged(); + return; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10000); try { @@ -145,12 +212,14 @@ export function createAgentTankRoutes() { const DEFAULT_URL = 'http://host.docker.internal:3456'; try { const settings = await configManager.loadAgentTankSettings(); - // If already enabled, no need to detect - if (settings.enabled) { + // Only offer the banner when tracking is entirely off. + if (settings.mode !== 'disabled') { res.json({ detected: false, reason: 'already_enabled' }); return; } - // Try to detect Agent Tank at default URL + // An external instance, if one happens to be running, wins the offer so + // we point the operator at what they already set up. Otherwise bundled is + // suggested: it is the lower-friction option and needs nothing installed. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); try { @@ -160,14 +229,18 @@ export function createAgentTankRoutes() { const data = await response.json(); // Check if we got valid agent data const hasAgents = data && typeof data === 'object' && Object.keys(data).length > 0; - res.json({ detected: hasAgents, url: DEFAULT_URL }); - } else { - res.json({ detected: false }); + if (hasAgents) { + res.json({ detected: true, mode: 'external', url: DEFAULT_URL }); + return; + } } } catch { clearTimeout(timer); - res.json({ detected: false }); } + // Only offer bundled when it would actually report something: a fresh + // install with no authenticated agent would just get an empty sidebar. + const bundledUsable = await canRunBundledAgentTank(); + res.json(bundledUsable ? { detected: true, mode: 'bundled' } : { detected: false }); } catch (error) { console.error('Error in /api/config/agent-tank/detect GET:', error); res.json({ detected: false }); diff --git a/packages/api/services/agentTankUsageWatcher.ts b/packages/api/services/agentTankUsageWatcher.ts index 4b409e238..49072a4b5 100644 --- a/packages/api/services/agentTankUsageWatcher.ts +++ b/packages/api/services/agentTankUsageWatcher.ts @@ -1,5 +1,5 @@ import * as configManager from '@propr/core'; -import { agentTankUsageFingerprint, type AgentStatusResponse } from '@propr/core'; +import { agentTankUsageFingerprint, type AgentTankSettings, type AgentStatusResponse } from '@propr/core'; import { USAGE_UPDATE } from '@propr/shared'; import { getSocketService } from './socketService.js'; @@ -15,26 +15,19 @@ import { getSocketService } from './socketService.js'; */ const DEFAULT_PROBE_INTERVAL_MS = 60_000; -const PROBE_TIMEOUT_MS = 5_000; export interface AgentTankUsageWatcherOptions { intervalMs?: number; /** Test seam; production reads the stored Agent Tank settings. */ - loadSettings?: () => Promise<{ enabled: boolean; url: string }>; + loadSettings?: () => Promise>; /** Test seam; production reads the provider snapshot Agent Tank exposes. */ - probe?: (url: string, signal: AbortSignal) => Promise; + readStatuses?: () => Promise | undefined>; /** Test seam; production broadcasts to this instance's operational clients. */ publish?: () => void; /** Whether anyone is connected to be told. */ hasListeners?: () => boolean; } -async function probeAgentTank(url: string, signal: AbortSignal): Promise { - const response = await fetch(`${url}/status`, { signal }); - if (!response.ok) return `HTTP ${response.status}`; - return response.json(); -} - function publishUsageChanged(): void { getSocketService()?.broadcastPushEvent({ eventType: USAGE_UPDATE, @@ -44,8 +37,8 @@ function publishUsageChanged(): void { export class AgentTankUsageWatcher { private readonly intervalMs: number; - private readonly loadSettings: () => Promise<{ enabled: boolean; url: string }>; - private readonly probe: (url: string, signal: AbortSignal) => Promise; + private readonly loadSettings: () => Promise>; + private readonly readStatuses: () => Promise | undefined>; private readonly publish: () => void; private readonly hasListeners: () => boolean; private timer: NodeJS.Timeout | undefined; @@ -58,7 +51,7 @@ export class AgentTankUsageWatcher { this.intervalMs = options.intervalMs ?? DEFAULT_PROBE_INTERVAL_MS; this.loadSettings = options.loadSettings ?? (() => configManager.loadAgentTankSettings()); - this.probe = options.probe ?? probeAgentTank; + this.readStatuses = options.readStatuses ?? (() => configManager.getAgentTankStatuses()); this.publish = options.publish ?? publishUsageChanged; this.hasListeners = options.hasListeners ?? (() => getSocketService()?.hasConnectedClients() ?? false); @@ -114,30 +107,28 @@ export class AgentTankUsageWatcher { /** The current usage snapshot, or null when it could not be read at all. */ private async readSnapshot(): Promise { - let settings: { enabled: boolean; url: string }; + let settings: Pick; try { settings = await this.loadSettings(); } catch { return null; } - if (!settings.enabled) return 'disabled'; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + if (settings.mode === 'disabled') return 'disabled'; + // A retained external URL is irrelevant to bundled observations. + const source = settings.mode === 'external' ? settings.url : 'bundled'; try { - const status = await this.probe(settings.url, controller.signal); + const status = await this.readStatuses(); // Preserve provider membership and errors, but ignore countdowns and // refresh timestamps just like the other Agent Tank observers. const fingerprint = status && typeof status === 'object' ? Object.entries(status as Record) .sort(([a], [b]) => a.localeCompare(b)) .map(([name, agent]) => [name, agentTankUsageFingerprint({ ...agent, name: agent.name || name })]) - : status; - return JSON.stringify({ url: settings.url, status: fingerprint }); + : 'unreachable'; + return JSON.stringify({ source, status: fingerprint }); } catch { // An unreachable Agent Tank is itself a change the sidebar shows. - return JSON.stringify({ url: settings.url, status: 'unreachable' }); - } finally { - clearTimeout(timer); + return JSON.stringify({ source, status: 'unreachable' }); } } } diff --git a/packages/api/services/shellActivityBroadcaster.ts b/packages/api/services/shellActivityBroadcaster.ts index 3bed9bca2..335b06461 100644 --- a/packages/api/services/shellActivityBroadcaster.ts +++ b/packages/api/services/shellActivityBroadcaster.ts @@ -1,5 +1,5 @@ import type { Server } from 'socket.io'; -import { agentTankUsageFingerprint, loadAgentTankSettings, normalizeAgentTankAgents, type AgentStatusResponse } from '@propr/core'; +import { agentTankUsageFingerprint, loadAgentTankSettings, getAgentTankStatuses, type AgentStatusResponse } from '@propr/core'; import { ACTIVITY_UPDATE, USAGE_UPDATE } from '@propr/shared'; import { ACTIVITY_ROOM } from './activitySocketRooms.js'; @@ -30,12 +30,10 @@ export class ShellActivityBroadcaster { constructor(private io: Server, private readStatus?: () => Promise>, private readUsage = async (): Promise => { const settings = await loadAgentTankSettings(); - if (!settings.enabled) return { enabled: false }; + if (settings.mode === 'disabled') return { enabled: false }; try { - const response = await fetch(`${settings.url}/status`, { signal: AbortSignal.timeout(5000) }); - if (!response.ok) return { enabled: true, error: `HTTP ${response.status}` }; - const agents = normalizeAgentTankAgents(await response.json() as Record); - return { enabled: true, agents }; + const agents = await getAgentTankStatuses(); + return agents ? { enabled: true, agents } : { enabled: true, error: 'unreachable' }; } catch { return { enabled: true, error: 'unreachable' }; } diff --git a/packages/api/test/agentTankSettingsPublish.test.ts b/packages/api/test/agentTankSettingsPublish.test.ts index 7d55df124..97df10129 100644 --- a/packages/api/test/agentTankSettingsPublish.test.ts +++ b/packages/api/test/agentTankSettingsPublish.test.ts @@ -47,18 +47,20 @@ describe('agent tank settings publishing', { concurrency: false }, () => { ); assert.deepEqual(body(), { success: true }); - assert.deepEqual(savedSettings, [{ enabled: true, url: 'http://agent-tank.test' }]); + assert.deepEqual(savedSettings, [{ mode: 'external', url: 'http://agent-tank.test' }]); assert.deepEqual(published.map(payload => payload.eventType), ['usage:update']); }); test('tells them about a disable too, so the widget can leave', async () => { - const { response } = responseRecorder(); + const { response, body } = responseRecorder(); await createAgentTankRoutes().postAgentTankSettings( { body: { enabled: false, url: 'http://agent-tank.test' } } as Request, response, ); + assert.deepEqual(body(), { success: true }); + assert.deepEqual(savedSettings, [{ mode: 'disabled', url: 'http://agent-tank.test' }]); assert.deepEqual(published.map(payload => payload.eventType), ['usage:update']); }); }); diff --git a/packages/api/test/agentTankUsageObservation.test.ts b/packages/api/test/agentTankUsageObservation.test.ts index c4cd545e6..daa55f214 100644 --- a/packages/api/test/agentTankUsageObservation.test.ts +++ b/packages/api/test/agentTankUsageObservation.test.ts @@ -5,18 +5,38 @@ */ import assert from 'node:assert/strict'; -import { after, beforeEach, mock, test } from 'node:test'; +import { after, before, beforeEach, mock, test } from 'node:test'; import type { Request as ExpressRequest, Response as ExpressResponse } from 'express'; import type { AgentStatusResponse } from '@propr/core'; // Imported after NODE_ENV, so core's GitHub auth module does not decide this // process is a misconfigured server and exit. process.env.NODE_ENV ??= 'test'; +let bundledSnapshot: Record | undefined; +let bundledReads = 0; +let bundledGate: Promise | undefined; +const runnerMock = await mock.module('../../core/src/services/agentTankBundledRunner.js', { + namedExports: { + buildBundledAgentTankConfig: () => '', + canRunBundledAgentTank: async () => true, + parseBundledAgentTankOutput: () => ({}), + getCachedBundledStatuses: () => bundledSnapshot, + getBundledStatusesForDelta: () => bundledSnapshot, + getBundledStatusForAlias: () => undefined, + clearBundledAgentTankCache: () => {}, + scheduleBundledRefresh: () => {}, + refreshBundledStatuses: async () => { + bundledReads += 1; + await bundledGate; + return bundledSnapshot; + }, + }, +}); const core = await import('@propr/core'); type AgentMap = Record; -const settings = { enabled: true, url: 'http://agent-tank.test' }; +const settings = { mode: 'external' as const, url: 'http://agent-tank.test' }; const observed: AgentMap[] = []; let published = 0; const originalFetch = globalThis.fetch; @@ -24,7 +44,6 @@ const originalFetch = globalThis.fetch; const coreMock = await mock.module('@propr/core', { namedExports: { ...core, - loadAgentTankSettings: async () => settings, // Real change detection, test publisher: what matters here is that the route // feeds its snapshot through the shared observer at all. observeAgentTankUsageSnapshot: async (agents: AgentMap) => { @@ -36,13 +55,20 @@ const coreMock = await mock.module('@propr/core', { const { createAgentTankRoutes } = await import('../routes/configRoutesAgentTank.js'); const { ShellActivityBroadcaster } = await import('../services/shellActivityBroadcaster.js'); +const { AgentTankUsageWatcher } = await import('../services/agentTankUsageWatcher.js'); const { ACTIVITY_ROOM } = await import('../services/activitySocketRooms.js'); const { USAGE_UPDATE } = await import('@propr/shared'); +// The route and the shared status service must read the same persisted mode. +// Mocking the barrel's settings export does not replace the service's internal +// config import, which would otherwise see the default disabled mode. +before(async () => { await core.runMigrations(); }); after(async () => { coreMock.restore(); + runnerMock.restore(); globalThis.fetch = originalFetch; + await core.db('system_configs').where('key', 'agent_tank').delete(); await core.closeConnection(); }); @@ -75,10 +101,14 @@ async function readUsage(): Promise { return record.body; } -beforeEach(() => { +beforeEach(async () => { + bundledSnapshot = undefined; + bundledReads = 0; + bundledGate = undefined; observed.length = 0; published = 0; core.resetAgentTankUsageTracking(); + await core.saveAgentTankSettings(settings); }); test('a changed aggregate usage read publishes a trigger', async () => { @@ -112,7 +142,7 @@ test('an unchanged aggregate usage read publishes nothing', async () => { test('a usage read that Agent Tank refuses observes nothing', async () => { globalThis.fetch = (async () => new globalThis.Response('nope', { status: 503 })) as typeof globalThis.fetch; - assert.deepEqual(await readUsage(), { enabled: true, error: 'HTTP 503' }); + assert.deepEqual(await readUsage(), { enabled: true, mode: 'external', error: 'unreachable' }); assert.equal(observed.length, 0); }); @@ -142,13 +172,14 @@ test('shell sampling ignores countdowns and ordering but detects quota, membersh tankReports({ agy: 0, claude: 43 }); await sampler.sample(); assert.equal(events.length, 3, 'agent ordering is not a change'); tankReports({ claude: 43 }); await sampler.sample(); - settings.enabled = false; await sampler.sample(); await sampler.sample(); - settings.enabled = true; await sampler.sample(); + await core.saveAgentTankSettings({ ...settings, mode: 'disabled' }); + await sampler.sample(); await sampler.sample(); + await core.saveAgentTankSettings(settings); await sampler.sample(); assert.equal(events.length, 6, 'quota, additions, removals and enable transitions each invalidate'); - } finally { settings.enabled = true; sampler.close(); } + } finally { sampler.close(); } }); -test('shell sampling announces HTTP, unreachable and recovery transitions once each', async () => { +test('shell sampling announces unavailable and recovery outcomes once each', async () => { const { sampler, events } = shellSampler(); try { tankReports({ claude: 42 }); await sampler.sample(); @@ -157,12 +188,12 @@ test('shell sampling announces HTTP, unreachable and recovery transitions once e assert.equal(events.length, 2); globalThis.fetch = async () => { throw new Error('network unavailable'); }; await sampler.sample(); await sampler.sample(); - assert.equal(events.length, 3); + assert.equal(events.length, 2, 'HTTP and network failures share the router’s unavailable outcome'); globalThis.fetch = async () => new Response('invalid JSON'); await sampler.sample(); - assert.equal(events.length, 3, 'body failures have the same unreachable outcome as the endpoint'); + assert.equal(events.length, 2, 'body failures have the same unreachable outcome as the endpoint'); tankReports({ claude: 42 }); await sampler.sample(); await sampler.sample(); - assert.equal(events.length, 4, 'recovery invalidates even when the quota did not change'); + assert.equal(events.length, 3, 'recovery invalidates even when the quota did not change'); } finally { sampler.close(); } }); @@ -182,3 +213,67 @@ test('closing during an awaited usage body prevents a late invalidation', async await pending; assert.deepEqual(events, []); }); + +for (const kind of ['shell', 'watcher'] as const) { + function observer() { + if (kind === 'shell') { + const { sampler, events } = shellSampler(); + return { sample: () => sampler.sample(), close: () => sampler.close(), events }; + } + const events: string[] = []; + const watcher = new AgentTankUsageWatcher({ + hasListeners: () => true, + publish: () => { events.push(USAGE_UPDATE); }, + }); + return { sample: () => watcher.probeOnce(), close: () => watcher.close(), events }; + } + + test(`${kind} observes bundled quota changes without contacting the saved external URL`, async () => { + await core.saveAgentTankSettings({ mode: 'bundled', url: settings.url }); + let httpReads = 0; + globalThis.fetch = async () => { httpReads += 1; throw new Error('No external daemon'); }; + const sampler = observer(); + const report = (percent: number, countdown: number) => { + bundledSnapshot = { claude: { name: 'claude', usage: { session: { percent, resetsInSeconds: countdown } } } }; + }; + try { + report(42, 120); + await sampler.sample(); + report(42, 90); + await sampler.sample(); + assert.deepEqual(sampler.events, [USAGE_UPDATE]); + report(49, 60); + await sampler.sample(); + await sampler.sample(); + assert.equal(sampler.events.length, 2, 'changed bundled usage wakes the sidebar exactly once'); + bundledSnapshot = undefined; + await sampler.sample(); await sampler.sample(); + assert.equal(sampler.events.length, 3, 'unavailable data is observed once'); + report(49, 30); + await sampler.sample(); + assert.equal(sampler.events.length, 4, 'recovery is observable'); + await core.saveAgentTankSettings({ mode: 'disabled' }); + const reads = bundledReads; + await sampler.sample(); await sampler.sample(); + assert.equal(sampler.events.length, 5); + assert.equal(bundledReads, reads, 'disabled mode never invokes the runner'); + assert.equal(httpReads, 0, 'bundled and disabled sampling never use HTTP'); + } finally { await sampler.close(); } + }); + + test(`${kind} suppresses overlapping bundled probes and publication after close`, { timeout: 5000 }, async () => { + await core.saveAgentTankSettings({ mode: 'bundled' }); + let release!: () => void; + bundledGate = new Promise(resolve => { release = resolve; }); + const sampler = observer(); + const pending = sampler.sample(); + // Wait for the persisted settings read to reach the held bundled refresh. + while (!bundledReads) await new Promise(resolve => setImmediate(resolve)); + await sampler.sample(); + assert.equal(bundledReads, 1); + await sampler.close(); + release(); + await pending; + assert.deepEqual(sampler.events, []); + }); +} diff --git a/packages/api/test/agentTankUsageWatcher.test.ts b/packages/api/test/agentTankUsageWatcher.test.ts index aa1302c12..7c7aeef07 100644 --- a/packages/api/test/agentTankUsageWatcher.test.ts +++ b/packages/api/test/agentTankUsageWatcher.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, describe, test } from 'node:test'; -import { closeConnection } from '@propr/core'; +import { closeConnection, type AgentTankSettings, type AgentStatusResponse } from '@propr/core'; import { AgentTankUsageWatcher } from '../services/agentTankUsageWatcher.js'; after(async () => closeConnection()); @@ -9,25 +9,25 @@ interface WatcherHarness { watcher: AgentTankUsageWatcher; published: number; setStatus(status: unknown): void; - setSettings(settings: { enabled: boolean; url: string }): void; + setSettings(settings: Pick): void; setListeners(listening: boolean): void; } function createWatcher(): WatcherHarness { let status: unknown = { claude: { usage: { session: { percent: 1 } } } }; - let settings = { enabled: true, url: 'http://agent-tank.test' }; + let settings: Pick = { mode: 'external', url: 'http://agent-tank.test' }; let listening = true; const harness = { published: 0, setStatus(next: unknown) { status = next; }, - setSettings(next: { enabled: boolean; url: string }) { settings = next; }, + setSettings(next: Pick) { settings = next; }, setListeners(next: boolean) { listening = next; }, } as WatcherHarness; harness.watcher = new AgentTankUsageWatcher({ loadSettings: async () => settings, - probe: async () => { + readStatuses: async () => { if (status instanceof Error) throw status; - return status; + return status as Record | undefined; }, publish: () => { harness.published += 1; }, hasListeners: () => listening, @@ -72,31 +72,30 @@ describe('agent tank usage watcher', { concurrency: false }, () => { { claude }, { claude: { ...claude, error: 'Quota unavailable' } }, { claude }, - 'HTTP 503', - new Error('Agent Tank is unreachable'), + undefined, { claude }, ]) { harness.setStatus(status); assert.equal(await harness.watcher.probeOnce(), true); assert.equal(await harness.watcher.probeOnce(), false); } - assert.equal(harness.published, 8); + assert.equal(harness.published, 7); }); test('preserves configured URL changes even when usage is identical', async () => { const harness = createWatcher(); await harness.watcher.probeOnce(); - harness.setSettings({ enabled: true, url: 'http://replacement.test' }); + harness.setSettings({ mode: 'external', url: 'http://replacement.test' }); assert.equal(await harness.watcher.probeOnce(), true); assert.equal(await harness.watcher.probeOnce(), false); }); test('does not publish a snapshot that completes after shutdown', async () => { - let resolveProbe!: (status: unknown) => void; + let resolveProbe!: (status: Record) => void; let published = 0; const watcher = new AgentTankUsageWatcher({ - loadSettings: async () => ({ enabled: true, url: 'http://agent-tank.test' }), - probe: () => new Promise(resolve => { resolveProbe = resolve; }), + loadSettings: async () => ({ mode: 'external', url: 'http://agent-tank.test' }), + readStatuses: () => new Promise(resolve => { resolveProbe = resolve; }), publish: () => { published += 1; }, hasListeners: () => true, }); @@ -104,7 +103,7 @@ describe('agent tank usage watcher', { concurrency: false }, () => { await Promise.resolve(); assert.equal(await watcher.probeOnce(), false, 'overlapping probes are suppressed'); await watcher.close(); - resolveProbe({ claude: { usage: { session: { percent: 42 } } } }); + resolveProbe({ claude: { name: 'claude', usage: { session: { percent: 42 } } } }); assert.equal(await probing, false); assert.equal(published, 0); }); @@ -138,7 +137,7 @@ describe('agent tank usage watcher', { concurrency: false }, () => { const harness = createWatcher(); await harness.watcher.probeOnce(); - harness.setSettings({ enabled: false, url: 'http://agent-tank.test' }); + harness.setSettings({ mode: 'disabled', url: 'http://agent-tank.test' }); assert.equal(await harness.watcher.probeOnce(), true); assert.equal(harness.published, 2); diff --git a/packages/api/test/configRoutesAgentTank.test.ts b/packages/api/test/configRoutesAgentTank.test.ts new file mode 100644 index 000000000..8b20bbdda --- /dev/null +++ b/packages/api/test/configRoutesAgentTank.test.ts @@ -0,0 +1,105 @@ +/** + * Agent Tank config routes. + * + * These cover the two ways the mode can arrive at the backend: the current + * `{ mode }` body the UI/CLI/MCP send, and the legacy `{ enabled }` body an + * older client may still send during a rolling upgrade. + */ + +import { after, test } from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.NODE_ENV = 'test'; + +// One shared connection for the file: closing it per test would leave the next +// test unable to reacquire one. +const configManager = await import('@propr/core'); +const { createAgentTankRoutes } = await import('../routes/configRoutesAgentTank.js'); + +after(async () => { + await configManager.db('system_configs').whereIn('key', ['agent_tank']).delete(); + await configManager.closeConnection(); +}); + +function responseSpy() { + return { + statusCode: 200, + body: undefined as Record | undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: Record) { + this.body = payload; + return this; + }, + }; +} + +test('agent tank settings routes persist the mode and reject an unknown one', async () => { + await configManager.runMigrations(); + await configManager.db('system_configs').whereIn('key', ['agent_tank']).delete(); + + const routes = createAgentTankRoutes(); + + // Bundled mode carries no URL; the previously saved one must survive. + await configManager.saveAgentTankSettings({ mode: 'external', url: 'http://saved:3456' }); + let res = responseSpy(); + await routes.postAgentTankSettings({ body: { mode: 'bundled' } } as never, res as never); + assert.equal(res.statusCode, 200); + let saved = await configManager.loadAgentTankSettings(); + assert.equal(saved.mode, 'bundled'); + assert.equal(saved.url, 'http://saved:3456'); + assert.equal(saved.enabled, true); + + // A legacy `{ enabled: true }` body means "my host install", i.e. external. + res = responseSpy(); + await routes.postAgentTankSettings({ body: { enabled: true, url: 'http://legacy:3456' } } as never, res as never); + saved = await configManager.loadAgentTankSettings(); + assert.equal(saved.mode, 'external'); + assert.equal(saved.url, 'http://legacy:3456'); + + res = responseSpy(); + await routes.postAgentTankSettings({ body: { enabled: false } } as never, res as never); + assert.equal((await configManager.loadAgentTankSettings()).mode, 'disabled'); + + res = responseSpy(); + await routes.postAgentTankSettings({ body: { mode: 'sideways' } } as never, res as never); + assert.equal(res.statusCode, 400); + // A rejected write must not change the stored mode. + assert.equal((await configManager.loadAgentTankSettings()).mode, 'disabled'); + + // GET returns the mode alongside the derived boolean older clients read. + res = responseSpy(); + await routes.getAgentTankSettings({} as never, res as never); + assert.equal(res.body?.mode, 'disabled'); + assert.equal(res.body?.enabled, false); +}); + +test('disabled mode short-circuits status, usage and refresh without any transport', async () => { + await configManager.runMigrations(); + await configManager.saveAgentTankSettings({ mode: 'disabled', url: 'http://0.0.0.0:3456' }); + + const routes = createAgentTankRoutes(); + const originalFetch = globalThis.fetch; + let fetches = 0; + globalThis.fetch = (async () => { fetches += 1; return new Response('{}'); }) as typeof fetch; + + try { + const status = responseSpy(); + await routes.getAgentTankStatus({} as never, status as never); + assert.deepEqual(status.body, { available: false, reason: 'disabled' }); + + const usage = responseSpy(); + await routes.getAgentTankUsage({} as never, usage as never); + assert.deepEqual(usage.body, { enabled: false }); + + const refresh = responseSpy(); + await routes.postAgentTankRefresh({} as never, refresh as never); + assert.equal(refresh.body?.success, false); + + assert.equal(fetches, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/api/test/configRoutesAgentTankBundled.test.ts b/packages/api/test/configRoutesAgentTankBundled.test.ts new file mode 100644 index 000000000..e30785344 --- /dev/null +++ b/packages/api/test/configRoutesAgentTankBundled.test.ts @@ -0,0 +1,143 @@ +/** + * Bundled Agent Tank readiness reporting. + * + * A bundled run can succeed while describing no usable usage: with only + * OpenCode/Vibe enabled - or no enabled agent at all - the runner starts no + * container and returns an empty map, and a provider Agent Tank failed to read + * comes back as a status object carrying its error and empty usage. The Settings + * radio group turns "available" into a green "Bundled Agent Tank ready", so + * neither snapshot may be reported as available, and neither may be reported as + * a successful forced refresh. + * + * The transport is mocked because the assertion is about how the route reads the + * snapshot, not about Docker; the readiness predicate itself is the real one. + */ + +import { mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.NODE_ENV = 'test'; + +const { hasAgentTankStatuses, hasUsableAgentTankStatuses } = await import('../../core/src/services/agentTankTypes.js'); + +let snapshot: Record | undefined; +const refreshCalls: Array<{ force?: boolean }> = []; + +await mock.module('@propr/core', { + namedExports: { + loadAgentTankSettings: async () => ({ mode: 'bundled', enabled: true, url: 'http://0.0.0.0:3456' }), + refreshBundledStatuses: async (options: { force?: boolean } = {}) => { + refreshCalls.push(options); + return snapshot; + }, + getAgentTankStatuses: async () => snapshot, + canRunBundledAgentTank: async () => false, + hasAgentTankStatuses, + hasUsableAgentTankStatuses, + }, +}); + +const { createAgentTankRoutes } = await import('../routes/configRoutesAgentTank.js'); + +function responseSpy() { + return { + statusCode: 200, + body: undefined as Record | undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: Record) { + this.body = payload; + return this; + }, + }; +} + +test('a bundled snapshot with no provider is not reported as ready', async () => { + const routes = createAgentTankRoutes(); + snapshot = {}; + + const status = responseSpy(); + await routes.getAgentTankStatus({} as never, status as never); + assert.deepEqual(status.body, { available: false, mode: 'bundled', reason: 'no_supported_agents' }); + + // Same evidence rule for the explicit operator refresh: nothing was refreshed. + const refresh = responseSpy(); + await routes.postAgentTankRefresh({} as never, refresh as never); + assert.deepEqual(refresh.body, { success: false, error: 'no_supported_agents' }); +}); + +test('a bundled run that failed outright keeps its own reason', async () => { + const routes = createAgentTankRoutes(); + snapshot = undefined; + + const status = responseSpy(); + await routes.getAgentTankStatus({} as never, status as never); + assert.deepEqual(status.body, { available: false, mode: 'bundled', reason: 'bundled_run_failed' }); + + const refresh = responseSpy(); + await routes.postAgentTankRefresh({} as never, refresh as never); + assert.deepEqual(refresh.body, { success: false, error: 'bundled_run_failed' }); +}); + +test('a bundled snapshot describing a provider stays available', async () => { + const routes = createAgentTankRoutes(); + snapshot = { claude: { name: 'claude', usage: { session: { percent: 12 } } } }; + refreshCalls.length = 0; + + const status = responseSpy(); + await routes.getAgentTankStatus({} as never, status as never); + assert.deepEqual(status.body, { available: true, mode: 'bundled' }); + + const refresh = responseSpy(); + await routes.postAgentTankRefresh({} as never, refresh as never); + assert.deepEqual(refresh.body, { success: true }); + // The status probe reuses the cache; only the operator's refresh forces a run. + assert.deepEqual(refreshCalls, [{}, { force: true }]); +}); + +test('a bundled snapshot whose every provider failed is not reported as ready', async () => { + const routes = createAgentTankRoutes(); + // The upstream representation of a provider that could not be read: still a + // status object, still keyed by the provider, but carrying no usage at all. + snapshot = { + claude: { name: 'claude', usage: {}, error: 'Timeout waiting for usage data' }, + codex: { name: 'codex', usage: {}, error: 'Not authenticated' }, + }; + + const status = responseSpy(); + await routes.getAgentTankStatus({} as never, status as never); + assert.deepEqual(status.body, { available: false, mode: 'bundled', reason: 'no_usage_data' }); + + const refresh = responseSpy(); + await routes.postAgentTankRefresh({} as never, refresh as never); + assert.deepEqual(refresh.body, { success: false, error: 'no_usage_data' }); +}); + +test('a bundled snapshot with one working provider stays available despite another failing', async () => { + const routes = createAgentTankRoutes(); + snapshot = { + claude: { name: 'claude', usage: { session: { percent: 12 } }, error: null }, + codex: { name: 'codex', usage: {}, error: 'Timeout waiting for usage data' }, + }; + + const status = responseSpy(); + await routes.getAgentTankStatus({} as never, status as never); + assert.deepEqual(status.body, { available: true, mode: 'bundled' }); + + const refresh = responseSpy(); + await routes.postAgentTankRefresh({} as never, refresh as never); + assert.deepEqual(refresh.body, { success: true }); +}); + +test('a provider status with no usage fields is not usable evidence', () => { + // A key in the map only proves a provider was configured; readiness needs a + // number that actually came back. + assert.equal(hasAgentTankStatuses({ claude: { name: 'claude', usage: {} } }), true); + assert.equal(hasUsableAgentTankStatuses({ claude: { name: 'claude', usage: {} } }), false); + assert.equal( + hasUsableAgentTankStatuses({ claude: { name: 'claude', usage: { session: { percent: 0 } } } }), + true + ); +}); diff --git a/packages/api/test/mcpReviewerConcurrency.test.ts b/packages/api/test/mcpReviewerConcurrency.test.ts index 3b9aa1d6d..db4c02140 100644 --- a/packages/api/test/mcpReviewerConcurrency.test.ts +++ b/packages/api/test/mcpReviewerConcurrency.test.ts @@ -78,9 +78,14 @@ test('real MCP catalog and shared persistence reject stale repository and agent for (const [key, value] of Object.entries(settings)) assert.equal(read[key], value); assert.equal((await call('update_indexing_configuration', { enabled: false, agent_alias: '', fallback_agent_alias: '', custom_prompt: 'Bounded summaries' })).state, 'completed'); assert.equal((await call('get_indexing_configuration', {})).custom_prompt, 'Bounded summaries'); - assert.equal((await call('update_provider_policy', { enabled: false, url: 'http://localhost:3456' })).state, 'completed'); + assert.equal((await call('update_provider_policy', { mode: 'disabled', url: 'http://localhost:3456' })).state, 'completed'); + assert.equal((await call('get_provider_policy', {})).mode, 'disabled'); assert.equal((await call('get_provider_policy', {})).enabled, false); - await assert.rejects(call('update_provider_policy', { enabled: true, url: 'https://user:secret@example.com' })); + // Bundled mode is reachable without a url; external still requires one. + assert.equal((await call('update_provider_policy', { mode: 'bundled' })).state, 'completed'); + assert.equal((await call('get_provider_policy', {})).mode, 'bundled'); + await assert.rejects(call('update_provider_policy', { mode: 'external' })); + await assert.rejects(call('update_provider_policy', { mode: 'external', url: 'https://user:secret@example.com' })); principal.authorization.permissions = []; await assert.rejects(call('create_repository_configuration', { repository: 'acme/three', baseBranch: 'main' }), /instance.manage_settings/); } finally { diff --git a/packages/cli/src/api/agentTank.ts b/packages/cli/src/api/agentTank.ts index 9396068d0..a3ba6c155 100644 --- a/packages/cli/src/api/agentTank.ts +++ b/packages/cli/src/api/agentTank.ts @@ -1,16 +1,40 @@ /** * Agent Tank API * - * Agent Tank tracks LLM subscription usage. It is an external service (not a - * stack container) — toggling it is a backend setting, so these helpers go - * through the running ProPR API (`/api/config/agent-tank`). + * Agent Tank tracks LLM subscription usage. It is a backend setting rather than + * a stack container — in `bundled` mode ProPR runs the Agent Tank CLI inside the + * agent image, and in `external` mode it talks to an instance the operator runs + * — so these helpers go through the running ProPR API + * (`/api/config/agent-tank`). */ import { ApiClient, createApiClient } from "./index.js"; +import { + AGENT_TANK_LEGACY_BACKEND_MESSAGE, + agentTankModeFromLegacyEnabled, + buildAgentTankSettingsRequest, + isAgentTankMode, + supportsAgentTankModes, + type AgentTankMode, +} from "@propr/shared"; export interface AgentTankSettings { + mode: AgentTankMode; enabled: boolean; url?: string; + /** + * False when the backend predates integration modes and answered with only + * `{ enabled, url }`. Such a backend cannot store `bundled` at all, so the + * mode above is derived rather than reported. + */ + supportsModes?: boolean; +} + +/** Raw `GET` shape: a pre-mode backend omits `mode` entirely. */ +interface AgentTankSettingsResponse { + mode?: unknown; + enabled?: unknown; + url?: string; } const DEFAULT_AGENT_TANK_URL = "http://127.0.0.1:3456"; @@ -18,25 +42,49 @@ const DEFAULT_AGENT_TANK_URL = "http://127.0.0.1:3456"; /** Fetch the current Agent Tank settings. */ export async function getAgentTank(client?: ApiClient): Promise { const apiClient = client ?? (await createApiClient()); - const response = await apiClient.get("/api/config/agent-tank"); - return response.data; + const response = await apiClient.get("/api/config/agent-tank"); + const raw = response.data; + // Derive the mode when the backend does not report one, so `propr tank` + // prints what an older backend is actually doing instead of `undefined`. + const mode = isAgentTankMode(raw?.mode) + ? raw.mode + : agentTankModeFromLegacyEnabled(raw?.enabled); + return { + mode, + enabled: mode !== "disabled", + url: raw?.url, + supportsModes: supportsAgentTankModes(raw), + }; } -/** Enable or disable Agent Tank usage tracking, optionally setting the URL. */ +/** Set the Agent Tank integration mode, optionally setting the external URL. */ export async function setAgentTank( - enabled: boolean, + mode: AgentTankMode, url?: string, client?: ApiClient ): Promise { const apiClient = client ?? (await createApiClient()); - // Preserve the existing URL when the caller doesn't pass one. + // Preserve the existing URL when the caller doesn't pass one, so switching to + // bundled and back to external does not lose a hand-tuned endpoint. Bundled + // always reads the current settings anyway - see the compatibility check. let resolvedUrl = url; - if (!resolvedUrl) { - const current = await getAgentTank(apiClient); - resolvedUrl = current.url || DEFAULT_AGENT_TANK_URL; + let current: AgentTankSettings | undefined; + if (!resolvedUrl || mode === "bundled") { + current = await getAgentTank(apiClient); + resolvedUrl = resolvedUrl || current.url || DEFAULT_AGENT_TANK_URL; + } + + // A pre-mode backend reads only `{ enabled, url }`, so it would store this + // bundled request as "external, at the saved URL" and still answer success. + // `external` and `disabled` need no such guard: the request body carries the + // derived `enabled` flag those backends act on. + if (mode === "bundled" && current && !current.supportsModes) { + throw new Error(AGENT_TANK_LEGACY_BACKEND_MESSAGE); } - await apiClient.post("/api/config/agent-tank", { body: { enabled, url: resolvedUrl } }); - return { enabled, url: resolvedUrl }; + await apiClient.post("/api/config/agent-tank", { + body: buildAgentTankSettingsRequest(mode, resolvedUrl), + }); + return { mode, enabled: mode !== "disabled", url: resolvedUrl }; } diff --git a/packages/cli/src/commands/tankCommands.test.ts b/packages/cli/src/commands/tankCommands.test.ts new file mode 100644 index 000000000..ddfc1be9f --- /dev/null +++ b/packages/cli/src/commands/tankCommands.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { AGENT_TANK_LEGACY_BACKEND_MESSAGE } from "@propr/shared"; +import type { ApiClient } from "../api/index.js"; +import { getAgentTank, setAgentTank } from "../api/agentTank.js"; +import { parseTankMode } from "./tankCommands.js"; + +test("parseTankMode accepts the three modes plus the off/on aliases", () => { + assert.equal(parseTankMode("bundled"), "bundled"); + assert.equal(parseTankMode("external"), "external"); + assert.equal(parseTankMode("disabled"), "disabled"); + assert.equal(parseTankMode(" OFF "), "disabled"); + // `on` has always meant "use my host install", so it maps to external and + // never silently repoints an existing user at a container. + assert.equal(parseTankMode("on"), "external"); + assert.equal(parseTankMode("bundle"), undefined); + assert.equal(parseTankMode(""), undefined); +}); + +function fakeClient( + recorded: Array<{ endpoint: string; body?: unknown }>, + current: Record = { mode: "external", enabled: true, url: "http://saved:3456" }, +): ApiClient { + return { + get: async (endpoint: string) => { + recorded.push({ endpoint }); + return { data: current }; + }, + post: async (endpoint: string, options?: { body?: unknown }) => { + recorded.push({ endpoint, body: options?.body }); + return { data: { success: true } }; + }, + } as unknown as ApiClient; +} + +test("setAgentTank sends the mode and preserves the saved URL when none is given", async () => { + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + + const result = await setAgentTank("bundled", undefined, fakeClient(recorded)); + + assert.deepEqual(result, { mode: "bundled", enabled: true, url: "http://saved:3456" }); + assert.deepEqual(recorded.at(-1), { + endpoint: "/api/config/agent-tank", + body: { mode: "bundled", enabled: true, url: "http://saved:3456" }, + }); +}); + +test("setAgentTank sends an explicit URL without reading the current settings", async () => { + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + + const result = await setAgentTank("external", "http://127.0.0.1:9999", fakeClient(recorded)); + + assert.equal(result.url, "http://127.0.0.1:9999"); + assert.deepEqual(recorded, [{ + endpoint: "/api/config/agent-tank", + body: { mode: "external", enabled: true, url: "http://127.0.0.1:9999" }, + }]); +}); + +test("setAgentTank derives enabled false only for the disabled mode", async () => { + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + + assert.equal((await setAgentTank("disabled", "http://x:1", fakeClient(recorded))).enabled, false); + assert.equal((await setAgentTank("external", "http://x:1", fakeClient(recorded))).enabled, true); +}); + +test("external and disabled writes carry the enabled flag a pre-mode backend reads", async () => { + // An older backend handler reads `{ enabled, url }` and stores + // `enabled: !!enabled`. Sending `mode` alone would make it persist "off" + // while answering success, so the derived flag must travel with every write. + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + const legacy = { enabled: true, url: "http://legacy:3456" }; + + await setAgentTank("external", "http://legacy:3456", fakeClient(recorded, legacy)); + assert.deepEqual(recorded.at(-1)?.body, { + mode: "external", + enabled: true, + url: "http://legacy:3456", + }); + + await setAgentTank("disabled", "http://legacy:3456", fakeClient(recorded, legacy)); + assert.deepEqual(recorded.at(-1)?.body, { + mode: "disabled", + enabled: false, + url: "http://legacy:3456", + }); +}); + +test("bundled is refused against a backend that does not understand modes", async () => { + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + // Pre-mode backend: `{ enabled, url }` with no `mode` at all. + const client = fakeClient(recorded, { enabled: true, url: "http://legacy:3456" }); + + await assert.rejects( + setAgentTank("bundled", undefined, client), + (error: Error) => error.message === AGENT_TANK_LEGACY_BACKEND_MESSAGE, + ); + // Nothing was written: an unsupported mode must not be reported as applied. + assert.deepEqual(recorded.filter(entry => entry.body !== undefined), []); +}); + +test("getAgentTank derives the mode a pre-mode backend does not report", async () => { + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + + const legacyOn = await getAgentTank(fakeClient(recorded, { enabled: true, url: "http://legacy:3456" })); + assert.equal(legacyOn.mode, "external"); + assert.equal(legacyOn.supportsModes, false); + + const legacyOff = await getAgentTank(fakeClient(recorded, { enabled: false, url: "" })); + assert.equal(legacyOff.mode, "disabled"); +}); + +test("getAgentTank returns the backend settings unchanged", async () => { + const recorded: Array<{ endpoint: string; body?: unknown }> = []; + + const settings = await getAgentTank(fakeClient(recorded, { mode: "bundled", enabled: true, url: "" })); + + assert.equal(settings.mode, "bundled"); + assert.equal(settings.supportsModes, true); + assert.deepEqual(recorded, [{ endpoint: "/api/config/agent-tank" }]); +}); diff --git a/packages/cli/src/commands/tankCommands.ts b/packages/cli/src/commands/tankCommands.ts index 687285de0..aafaaf17e 100644 --- a/packages/cli/src/commands/tankCommands.ts +++ b/packages/cli/src/commands/tankCommands.ts @@ -1,14 +1,14 @@ /** - * `propr tank on|off` — toggle Agent Tank LLM usage tracking. + * `propr tank bundled|external|off` — configure Agent Tank LLM usage tracking. * - * Agent Tank is an external service, not a stack container, so this is a backend + * Agent Tank is a backend setting rather than a stack container, so this is a * setting flip routed through the running ProPR API. */ import { Command } from "commander"; +import { AGENT_TANK_MODES, type AgentTankMode } from "@propr/shared"; import { getAgentTank, setAgentTank } from "../api/agentTank.js"; import { NetworkError, UnauthorizedError } from "../api/errors.js"; -import { parseOnOffState, ParseStateError } from "../utils/index.js"; function handleApiError(error: unknown): never { if (error instanceof NetworkError) { @@ -21,33 +21,66 @@ function handleApiError(error: unknown): never { process.exit(1); } +/** + * `on` is kept as a deprecated alias for `external` rather than for `bundled`: + * an existing user typing `propr tank on` today means "use my host install", + * and silently repointing them at a container would change behavior under them. + */ +export function parseTankMode(value: string): AgentTankMode | undefined { + const normalized = value.trim().toLowerCase(); + if (normalized === "off") return "disabled"; + if (normalized === "on") return "external"; + return (AGENT_TANK_MODES as readonly string[]).includes(normalized) + ? normalized as AgentTankMode + : undefined; +} + +/** Only `external` talks to a URL, so only `external` prints one. */ +function describeSettings(mode: AgentTankMode, url?: string): string { + return mode === "external" && url ? `${mode} (${url})` : mode; +} + export function createTankCommand(): Command { const tank = new Command("tank") - .description("Toggle Agent Tank LLM usage tracking (requires the stack running)") - .argument("[state]", "on or off (omit to show current setting)") - .option("--url ", "Agent Tank service URL") + .description("Configure Agent Tank LLM usage tracking (requires the stack running)") + .argument("[mode]", "bundled, external, or off (omit to show the current mode)") + .option("--url ", "Agent Tank service URL (external mode only)") .addHelpText("after", ` +Modes: + bundled ProPR runs the Agent Tank CLI inside the agent image (no host install) + external Talk to an Agent Tank daemon you run yourself + off No usage tracking at all + Examples: - $ propr tank # show current setting - $ propr tank on + $ propr tank # show current mode + $ propr tank bundled + $ propr tank external --url http://127.0.0.1:3456 $ propr tank off - $ propr tank on --url http://127.0.0.1:3456 `) - .action(async (state: string | undefined, options: { url?: string }) => { + .action(async (mode: string | undefined, options: { url?: string }) => { try { - if (!state) { + if (!mode) { const current = await getAgentTank(); - console.log(`Agent Tank: ${current.enabled ? "on" : "off"}${current.url ? ` (${current.url})` : ""}`); + console.log(`Agent Tank: ${describeSettings(current.mode, current.url)}`); return; } - const enable = parseOnOffState(state); - const result = await setAgentTank(enable, options.url); - console.log(`Agent Tank ${result.enabled ? "enabled" : "disabled"}${result.url ? ` (${result.url})` : ""}.`); - } catch (error) { - if (error instanceof ParseStateError) { - console.error(`Error: ${error.message}`); + + const parsed = parseTankMode(mode); + if (!parsed) { + console.error(`Error: invalid mode "${mode}". Use one of: ${AGENT_TANK_MODES.join(", ")}, off`); + process.exit(1); + } + if (options.url && parsed !== "external") { + console.error(`Error: --url only applies to external mode.`); process.exit(1); } + if (mode.trim().toLowerCase() === "on") { + console.warn(`Note: "propr tank on" is deprecated; use "propr tank external".`); + } + + const result = await setAgentTank(parsed, options.url); + console.log(`Agent Tank set to ${describeSettings(result.mode, result.url)}.`); + } catch (error) { handleApiError(error); } }); diff --git a/packages/core/src/agents/impl/AntigravityAgent.ts b/packages/core/src/agents/impl/AntigravityAgent.ts index 0bb36016e..24ea7c597 100644 --- a/packages/core/src/agents/impl/AntigravityAgent.ts +++ b/packages/core/src/agents/impl/AntigravityAgent.ts @@ -123,7 +123,9 @@ export class AntigravityAgent implements Agent { async () => executeDockerCommand('docker', dockerArgs, { timeout: this.timeoutMs, cwd: worktreePath, onSessionId, onContainerId, worktreePath, stdinData: prompt, taskId, streamToRedis: true, preserveOutputOnTimeout: true - }) + }), + undefined, + this.config.alias ); const executionTime = Date.now() - startTime; @@ -346,7 +348,8 @@ export class AntigravityAgent implements Agent { const { result, usageMetrics } = await executeWithUsageTracking( this.getRuntimeName(), async () => executeDockerCommand('docker', dockerArgs, { timeout: effectiveTimeoutMs, stdinData: fullPrompt, taskId }), - ANALYSIS_AGENT_TANK_TIMEOUT_MS + ANALYSIS_AGENT_TANK_TIMEOUT_MS, + this.config.alias ); const executionTimeMs = Date.now() - startTime; const { summary, tokenUsage, sessionId, modelUsed, terminalStatus, protocolError, hasStreamEnvelopes } = parseAntigravityJsonl(result.stdout); diff --git a/packages/core/src/agents/impl/ClaudeAgent.ts b/packages/core/src/agents/impl/ClaudeAgent.ts index 9c0fa16bc..662116aea 100644 --- a/packages/core/src/agents/impl/ClaudeAgent.ts +++ b/packages/core/src/agents/impl/ClaudeAgent.ts @@ -147,7 +147,9 @@ export class ClaudeAgent implements Agent { timeout: this.timeoutMs, cwd: worktreePath, onSessionId, onContainerId, worktreePath, stdinData: prompt, taskId, streamToRedis: true, preserveOutputOnTimeout: true - }) + }), + undefined, + this.config.alias ); const executionTime = Date.now() - startTime; @@ -280,7 +282,8 @@ export class ClaudeAgent implements Agent { async () => executeDockerCommand('docker', dockerArgs, { timeout: timeoutMs ?? 1800000, stdinData: analysisPrompt, taskId }), - ANALYSIS_AGENT_TANK_TIMEOUT_MS + ANALYSIS_AGENT_TANK_TIMEOUT_MS, + this.config.alias ); const executionTimeMs = Date.now() - startTime; diff --git a/packages/core/src/agents/impl/CodexAgent.ts b/packages/core/src/agents/impl/CodexAgent.ts index e8a08d278..49bce317a 100644 --- a/packages/core/src/agents/impl/CodexAgent.ts +++ b/packages/core/src/agents/impl/CodexAgent.ts @@ -83,7 +83,9 @@ export class CodexAgent implements Agent { taskId, streamToRedis: true, preserveOutputOnTimeout: true - }) + }), + undefined, + this.config.alias ); const executionTime = Date.now() - startTime; @@ -246,7 +248,8 @@ export class CodexAgent implements Agent { async () => executeDockerCommand('docker', dockerArgs, { timeout: timeoutMs ?? 1800000, stdinData: analysisPrompt, taskId }), - ANALYSIS_AGENT_TANK_TIMEOUT_MS + ANALYSIS_AGENT_TANK_TIMEOUT_MS, + this.config.alias ); const executionTimeMs = Date.now() - startTime; diff --git a/packages/core/src/agents/impl/utils/usageTrackingWrapper.ts b/packages/core/src/agents/impl/utils/usageTrackingWrapper.ts index 044c3fad1..b6589b268 100644 --- a/packages/core/src/agents/impl/utils/usageTrackingWrapper.ts +++ b/packages/core/src/agents/impl/utils/usageTrackingWrapper.ts @@ -88,14 +88,19 @@ export interface UsageTrackingMetrics { /** * Returns true when Agent Tank tracking is enabled. * - * Checks the database settings for the Agent Tank configuration. - * Tracking is disabled when enabled is false or url is empty/invalid. + * Checks the database settings for the Agent Tank configuration. Tracking is + * off in `disabled` mode, and in `external` mode when the URL is empty or + * invalid. `bundled` mode needs no URL — it runs the CLI in the agent image. */ export async function isAgentTankEnabled(): Promise { try { const settings = await loadAgentTankSettings(); - const enabled = settings.enabled && !!settings.url && settings.url !== 'false' && settings.url !== '0'; - logger.info({ enabled, settings }, 'Agent Tank enabled check'); + // Bundled mode contacts no URL at all, so the URL sanity checks only + // apply to the external transport. + const enabled = settings.mode === 'bundled' + || (settings.mode === 'external' + && !!settings.url && settings.url !== 'false' && settings.url !== '0'); + logger.info({ enabled, mode: settings.mode }, 'Agent Tank enabled check'); return enabled; } catch (err) { logger.warn({ error: (err as Error).message }, 'Failed to load Agent Tank settings, assuming disabled'); @@ -225,15 +230,33 @@ function extractArrayMetricRecords( /** * Refresh the agent and then fetch its current status. * - * Always calls POST /refresh/:agent first to ensure Agent Tank has the - * latest data, then calls GET /status/:agent to retrieve it. + * External mode refreshes over HTTP. Bundled mode awaits the phase-specific + * refresh with its own budget before reading the snapshot. */ async function refreshAndGetStatus( agent: string, - timeoutMs?: number, + timeoutMs: number | undefined, + alias: string, + phase: 'pre-call' | 'post-call', ): Promise { - await refreshAgent(agent, timeoutMs); - return getStatus(agent, timeoutMs); + await refreshAgent(agent, timeoutMs, phase); + return getStatus(agent, timeoutMs, alias); +} + +/** + * Whether the post-call status is the *same snapshot* the pre-call read returned. + * + * A successful refresh can still return unchanged provider data. Subtracting + * that snapshot from itself is not evidence of this call's consumption. + * + * `lastUpdated` is the transport-independent identity of a snapshot - Agent Tank + * stamps it when it reads the CLI - and the usage payload is compared too so a + * daemon that reports new numbers under an unchanged timestamp still counts as a + * measurement. + */ +function isSameSnapshot(preCall: AgentStatusResponse, postCall: AgentStatusResponse): boolean { + return preCall.lastUpdated === postCall.lastUpdated + && JSON.stringify(preCall.usage) === JSON.stringify(postCall.usage); } function isAgentTankTimeout(error: unknown): boolean { @@ -252,9 +275,10 @@ async function fetchStatusBestEffort( agent: string, phase: 'pre-call' | 'post-call', timeoutMs?: number, + alias: string = agent, ): Promise { try { - const status = await refreshAndGetStatus(agent, timeoutMs); + const status = await refreshAndGetStatus(agent, timeoutMs, alias, phase); logger.debug({ agent, phase, usage: status.usage }, `Agent Tank ${phase} status`); return status; } catch (err: unknown) { @@ -273,11 +297,12 @@ function startStatusSnapshot( agent: string, phase: 'pre-call' | 'post-call', timeoutMs?: number, + alias: string = agent, ): StatusSnapshotHandle { let settled = false; let settledStatus: AgentStatusResponse | null = null; - const promise = fetchStatusBestEffort(agent, phase, timeoutMs).then(status => { + const promise = fetchStatusBestEffort(agent, phase, timeoutMs, alias).then(status => { settled = true; settledStatus = status; return status; @@ -297,21 +322,26 @@ function startStatusSnapshot( * 3. Uses the pre-call snapshot only if it is already available when the LLM * call finishes. * 4. Refreshes the agent again and fetches status (post-call). - * 5. Computes the delta and extracts structured metric records. - * 6. Returns both the execution result and the usage metrics. + * 5. Skips the measurement when both probes returned the same snapshot. + * Bundled post-call probes await a new run with a bundled-specific timeout. + * 6. Computes the delta and extracts structured metric records. + * 7. Returns both the execution result and the usage metrics. * * If Agent Tank is disabled or a status fetch fails, the LLM call still - * proceeds — usage tracking is best-effort and never blocks execution. + * proceeds — usage tracking never delays starting execution. Returning the + * result may wait for the bounded post-call probe. * * @param agent - The agent identifier to query (e.g. "claude", "antigravity", "codex"). * @param executeFn - An async function that performs the LLM call and returns its result. - * @param timeoutMs - Optional timeout for each Agent Tank HTTP request (default: 5000ms). + * @param timeoutMs - Optional timeout for each HTTP request; bundled mode uses AGENT_TANK_BUNDLED_TIMEOUT_MS. + * @param alias - Executing account alias; bundled probes require matching cached provenance. * @returns The execution result and usage metrics (metrics are null if tracking was skipped). */ export async function executeWithUsageTracking( agent: string, executeFn: () => Promise, timeoutMs?: number, + alias: string = agent, ): Promise> { if (!(await isAgentTankEnabled())) { logger.debug({ agent }, 'Agent Tank disabled — skipping usage tracking'); @@ -322,7 +352,7 @@ export async function executeWithUsageTracking( // Pre-call: start Agent Tank refresh/status capture, but do not wait before // launching the LLM. This keeps local usage monitoring from adding latency // to model execution, especially for indexing analysis batches. - const preCallSnapshot = startStatusSnapshot(agent, 'pre-call', timeoutMs); + const preCallSnapshot = startStatusSnapshot(agent, 'pre-call', timeoutMs, alias); // Execute the LLM call (always runs, even if pre-call failed) const result = await executeFn(); @@ -339,11 +369,19 @@ export async function executeWithUsageTracking( return { result, usageMetrics: null }; } - const postCall = await fetchStatusBestEffort(agent, 'post-call', timeoutMs); + const postCall = await fetchStatusBestEffort(agent, 'post-call', timeoutMs, alias); if (postCall === null) { return { result, usageMetrics: null }; } + // Both probes read the same snapshot, so there is nothing this call can be + // said to have consumed. Report no metrics rather than a fabricated zero. + if (isSameSnapshot(preCall, postCall)) { + logger.debug({ agent, lastUpdated: preCall.lastUpdated }, + 'Agent Tank returned the same snapshot before and after the call — recording no usage delta'); + return { result, usageMetrics: null }; + } + // Compute delta const delta = calculateDelta( preCall.usage, diff --git a/packages/core/src/agents/version/types.ts b/packages/core/src/agents/version/types.ts index 4eff7d8f4..acf3d84a9 100644 --- a/packages/core/src/agents/version/types.ts +++ b/packages/core/src/agents/version/types.ts @@ -54,6 +54,7 @@ export { AGENT_IMAGE_NAME }; export const AGENT_BUNDLE_CONTENT_FILES = [ 'Dockerfile.agent', 'scripts/agent-entrypoint.sh', + 'scripts/agent-tank-runtime.mjs', 'scripts/claude-entrypoint.sh', 'scripts/codex-entrypoint.sh', 'scripts/antigravity-entrypoint.sh', diff --git a/packages/core/src/config/configManager.ts b/packages/core/src/config/configManager.ts index 0ff83d7bc..8c7b91c08 100644 --- a/packages/core/src/config/configManager.ts +++ b/packages/core/src/config/configManager.ts @@ -409,6 +409,8 @@ export { saveAgents, migrateAgentConfigs, type AgentTankSettings, + DEFAULT_AGENT_TANK_URL, + normalizeAgentTankSettings, loadAgentTankSettings, saveAgentTankSettings } from './configManagerAgents.js'; diff --git a/packages/core/src/config/configManagerAgents.ts b/packages/core/src/config/configManagerAgents.ts index a127f8a37..e57048d68 100644 --- a/packages/core/src/config/configManagerAgents.ts +++ b/packages/core/src/config/configManagerAgents.ts @@ -1,7 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { + agentTankModeFromLegacyEnabled, getManagedAgentConfigRelativePath, + normalizeAgentTankMode, + type AgentTankMode, type AgentType, type ReasoningLevel } from '@propr/shared'; @@ -236,29 +239,82 @@ export async function migrateAgentConfigs(): Promise { * Settings for Agent Tank integration (LLM usage monitoring). */ export interface AgentTankSettings { + /** Authoritative integration mode. */ + mode: AgentTankMode; + /** + * Derived convenience flag (`mode !== 'disabled'`). + * + * Kept so the existing `settings.enabled` call sites keep working without a + * sweeping refactor. Treat it as read-only: `mode` is the source of truth, + * and `saveAgentTankSettings` ignores whatever is passed here. + */ enabled: boolean; + /** Only meaningful in `external` mode. */ url: string; } -const DEFAULT_AGENT_TANK_SETTINGS: AgentTankSettings = { - enabled: false, - url: 'http://0.0.0.0:3456' -}; +export const DEFAULT_AGENT_TANK_URL = 'http://0.0.0.0:3456'; + +/** + * Environment fallback for headless/automated deployments that configure the + * stack entirely through `.env` and never open the Settings UI. Database + * settings still win; this only fills in a missing record. + */ +function environmentModeFallback(): AgentTankMode | undefined { + const raw = process.env.AGENT_TANK_MODE?.trim(); + if (!raw) return undefined; + const normalized = normalizeAgentTankMode(raw); + // normalizeAgentTankMode is total, so an unrecognized value silently becomes + // 'disabled'. Log it instead of pretending the operator asked for that. + if (normalized === 'disabled' && raw !== 'disabled') { + logger.warn({ AGENT_TANK_MODE: raw }, 'Unrecognized AGENT_TANK_MODE; treating Agent Tank as disabled'); + } + return normalized; +} + +/** + * Accepts both the current `{ mode, url }` shape and the legacy + * `{ enabled, url }` shape written before bundled mode existed. + */ +export function normalizeAgentTankSettings(raw: unknown): AgentTankSettings { + const record = (raw && typeof raw === 'object') ? raw as Record : {}; + const mode = 'mode' in record + ? normalizeAgentTankMode(record.mode) + // No `mode` key at all means this record predates the feature (or is + // empty). Derive from the legacy boolean, then from the environment. + : ('enabled' in record + ? agentTankModeFromLegacyEnabled(record.enabled) + : environmentModeFallback() ?? 'disabled'); + const url = typeof record.url === 'string' && record.url.trim() + ? record.url.trim() + : (process.env.AGENT_TANK_URL?.trim() || DEFAULT_AGENT_TANK_URL); + return { mode, enabled: mode !== 'disabled', url }; +} /** * Loads Agent Tank settings from the database. */ export async function loadAgentTankSettings(): Promise { - const settings = await getConfig('agent_tank', DEFAULT_AGENT_TANK_SETTINGS); - logger.info({ agentTank: settings }, 'Successfully loaded Agent Tank settings'); + // Read as `unknown`: the persisted value may be the legacy shape, and the + // normalizer is what guarantees callers only ever see the current one. + const raw = await getConfig('agent_tank', {}); + const settings = normalizeAgentTankSettings(raw); + logger.info({ agentTank: { mode: settings.mode } }, 'Successfully loaded Agent Tank settings'); return settings; } /** * Saves Agent Tank settings to the database. */ -export async function saveAgentTankSettings(settings: AgentTankSettings): Promise { - await saveConfig('agent_tank', settings); - logger.info({ agentTank: settings }, 'Successfully saved Agent Tank settings'); +export async function saveAgentTankSettings( + settings: Pick & Partial +): Promise { + // Persist the canonical shape only. `enabled` is intentionally written too, + // so that a rollback to an older build still reads a sane boolean instead of + // defaulting Agent Tank on/off arbitrarily. + const mode = normalizeAgentTankMode(settings.mode); + const persisted = { mode, enabled: mode !== 'disabled', url: settings.url || DEFAULT_AGENT_TANK_URL }; + await saveConfig('agent_tank', persisted); + logger.info({ agentTank: { mode } }, 'Successfully saved Agent Tank settings'); return true; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ac652a14..8cbc9a463 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -370,14 +370,29 @@ export { toAntigravityCliModelId } from './agents/impl/antigravityModelIds.js'; export { toAgentTankAgent, toProprAgent, + hasAgentTankStatuses, + hasUsableAgentTankStatuses, + isUsableAgentTankStatus, normalizeAgentTankStatus, normalizeAgentTankAgents, + getStatusForAlias as getAgentTankStatusForAlias, + getAllStatuses as getAgentTankStatuses, agentTankUsageFingerprint, observeAgentTankUsage, observeAgentTankUsageSnapshot, resetAgentTankUsageTracking } from './services/agentTankService.js'; export type { AgentStatusResponse } from './services/agentTankService.js'; +export { + buildBundledAgentTankConfig, + canRunBundledAgentTank, + parseBundledAgentTankOutput, + refreshBundledStatuses, + getCachedBundledStatuses, + getBundledStatusesForDelta, + getBundledStatusForAlias, + clearBundledAgentTankCache +} from './services/agentTankBundledRunner.js'; export type { BuildOpenCodePromptOptions, OpenCodeDockerArgsParams, OpenCodeEvent, ParsedOpenCodeOutput } from './agents/impl/openCodeUtils.js'; export { VibeAgent, parseVibeConversationLog, parseVibeOutput } from './agents/impl/VibeAgent.js'; export type { diff --git a/packages/core/src/services/agentTankBundledRunner.ts b/packages/core/src/services/agentTankBundledRunner.ts new file mode 100644 index 000000000..145d26115 --- /dev/null +++ b/packages/core/src/services/agentTankBundledRunner.ts @@ -0,0 +1,525 @@ +/** + * Bundled Agent Tank transport. + * + * Runs `agent-tank --once --json --config ` inside the unified + * `propr/agent` image, with each enabled agent's credential directory + * bind-mounted read-only at the same container path the agent runtime uses. + * No host install, no daemon, no container networking to get wrong. + * + * Everything Docker-specific (image resolution, mounts, timeouts) and every + * assumption about the Agent Tank config/output schema lives here, so the HTTP + * transport stays readable and an upstream schema change is a local edit. + */ + +import fs from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import logger from '../utils/logger.js'; +import { executeDockerCommand } from '../claude/docker/dockerExecutor.js'; +import { loadAgents, resolveConfigPath, resolveCodexConfigPath } from '../config/configManager.js'; +import { CONTAINER_CONFIG_PATHS } from '../agents/types.js'; +import type { AgentConfig } from '../agents/types.js'; +import { toAgentTankAgent, type AgentStatusResponse } from './agentTankTypes.js'; + +/** + * A bundled refresh starts a container and drives interactive `/usage` calls + * through a PTY, so it is slow by nature. Capacity reads use the cache; per-call + * measurements await a bounded refresh without delaying model execution. + */ +const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; +/** + * Provider usage windows move on the order of minutes, so a 60s snapshot is + * plenty fresh for a capacity gauge while keeping container churn near zero. + */ +const DEFAULT_CACHE_TTL_MS = 60_000; +/** + * Past this age a snapshot is too stale to subtract for a per-call delta: two + * LLM calls could both read the same snapshot and report a bogus zero delta, or + * a very old snapshot could attribute unrelated consumption to this call. We + * would rather record no delta than a wrong one. + */ +const DELTA_FRESHNESS_MS = 90_000; + +const CONTAINER_CONFIG_FILE = '/tmp/propr-agent-tank/config.json'; + +/** Carries the generated config into the container (see `CONFIG_BOOTSTRAP`). */ +const CONFIG_ENV_VAR = 'PROPR_AGENT_TANK_CONFIG'; + +/** + * Materialize the generated config *inside* the container instead of + * bind-mounting it from this process's filesystem. + * + * The backend normally runs in its own container and drives the host Docker + * daemon, so a backend-local pathname is not a usable bind source: the daemon + * resolves bind sources on the host, where the generated file does not exist, + * and would hand Agent Tank an empty directory instead of its config. No file + * mode or directory permission can bridge two filesystem namespaces. Every + * other mount in the run is a credential directory whose path already went + * through the deployment's host mapping (`resolveConfigPath` / + * `resolveCodexConfigPath`); the generated config has no such mapping, so it + * travels in the run itself and the container writes it as the user that reads + * it. The config holds provider keys and container paths only - no secrets - so + * an environment variable is a safe carrier. The `--rm` container takes the + * file with it, so there is nothing host-side left to clean up. + */ +const CONFIG_BOOTSTRAP = [ + 'set -e', + 'umask 077', + 'mkdir -p "$(dirname "$1")"', + `printf %s "$${CONFIG_ENV_VAR}" > "$1"`, + 'node /home/node/agent-tank-runtime.mjs "$1"', + 'exec agent-tank --once --json --config "$1"', +].join('; '); + +/** + * Agent Tank only knows these three providers (`SUPPORTED_PROVIDERS` upstream). + * OpenCode and Vibe have no usage endpoint to read, so including them would + * make the whole run exit non-zero on an "Unsupported agent provider" error. + */ +const BUNDLED_SUPPORTED_TANK_AGENTS = new Set(['claude', 'codex', 'agy']); + +/** + * One Agent Tank run: the per-provider snapshots plus which configured account + * each one actually describes. + */ +interface BundledRunResult { + agents: Record; + /** + * Provider key -> the alias of the enabled agent whose credentials were + * mounted for that provider. Agent Tank knows only providers, so this is the + * ONLY record of which configured account the numbers belong to: the + * generated id is the provider key, so two accounts of the same provider are + * indistinguishable from the snapshot itself. + */ + aliases: Record; +} + +interface CachedSnapshot extends BundledRunResult { + capturedAt: number; +} + +/** One entry of the generated Agent Tank `agents` array. */ +export interface BundledAgentTankEntry { + /** Agent Tank provider key (`claude`, `codex`, `agy`). */ + provider: string; + /** ProPR alias of the agent whose credentials are mounted for that provider. */ + alias: string; + /** Container path holding that provider's credentials. */ + configPath: string; +} + +/** A generated config entry together with the bind source that feeds it. */ +interface BundledCredentialSource { + /** + * Credential directory as the *Docker daemon* sees it - the deployment's + * host mapping, which is not necessarily a path in this process's + * filesystem (see `credentialSourceIsUsable`). + */ + hostPath: string; + entry: BundledAgentTankEntry; +} + +let cached: CachedSnapshot | undefined; +// Coalesces concurrent refresh requests onto a single container run. Without +// this, the sidebar poll and a task's post-call probe could each spawn one. +let inFlight: Promise | undefined; + +function timeoutMs(): number { + const parsed = Number.parseInt(process.env.AGENT_TANK_BUNDLED_TIMEOUT_MS || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_REFRESH_TIMEOUT_MS; +} + +function cacheTtlMs(): number { + const parsed = Number.parseInt(process.env.AGENT_TANK_BUNDLED_CACHE_TTL_MS || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CACHE_TTL_MS; +} + +/** + * Resolve the host path that holds this agent's credentials. + * + * Codex has its own resolver because its portable `~/.codex` default has to be + * mapped through the launcher's host mapping rather than expanded against the + * backend container's HOME. Reusing the agent runtime's own resolvers is what + * guarantees bundled Agent Tank inspects exactly the credentials the agent + * itself would use - not a lookalike directory. + */ +function hostCredentialPath(agent: AgentConfig): string | undefined { + try { + return agent.type === 'codex' + ? resolveCodexConfigPath(agent.configPath) + : resolveConfigPath(agent.configPath); + } catch (error) { + logger.debug({ agentAlias: agent.alias, error: (error as Error).message }, + 'Skipping agent for bundled Agent Tank: credential path is unavailable'); + return undefined; + } +} + +/** + * Whether this process and the Docker daemon share one filesystem namespace. + * + * The backend normally runs in its own container against the host daemon, so + * the two namespaces are different and a host pathname says nothing about what + * this process can stat. + */ +function backendSharesHostFilesystem(): boolean { + const flag = process.env.PROPR_CONTAINERIZED?.trim().toLowerCase(); + if (flag === '1' || flag === 'true') return false; + if (flag === '0' || flag === 'false') return true; + return !fs.existsSync('/.dockerenv'); +} + +/** + * Decide whether a resolved host credential path may be used as a bind source. + * + * A local `existsSync` hit is good news in either namespace. A miss only means + * anything when this process shares the daemon's filesystem: inside the backend + * container a credential directory that went through the deployment's host + * mapping (`HOST_CODEX_DIR`, a non-identity `*_CONFIG_PATH`, a managed root that + * is not mounted here) is perfectly valid for the daemon and simply invisible to + * us. Discarding it there would silently disable bundled mode - and suppress the + * detection banner's bundled offer - for a correctly configured install. What we + * cannot see, the daemon checks for us: credentials are mounted with + * `--mount type=bind`, which refuses to start the container when the source is + * missing on the host instead of inventing an empty directory the way `-v` does. + */ +function credentialSourceIsUsable(agent: AgentConfig, hostPath: string): boolean { + if (fs.existsSync(hostPath)) return true; + if (backendSharesHostFilesystem()) { + logger.debug({ agentAlias: agent.alias }, + 'Skipping agent for bundled Agent Tank: credential directory does not exist'); + return false; + } + logger.debug({ agentAlias: agent.alias }, + 'Bundled Agent Tank credential directory is not visible to the backend; letting the Docker daemon resolve it'); + return true; +} + +/** + * Render one `--mount` field, quoting the way Docker's CSV parser expects when + * the value contains a comma or quote (a path may legally contain either, and + * an unquoted comma would be read as the start of another field). + */ +function mountField(key: string, value: string): string { + return /[",]/.test(value) + ? `"${key}=${value.replace(/"/g, '""')}"` + : `${key}=${value}`; +} + +/** + * Read-only bind mounts for every credential source. + * + * `--mount` rather than `-v` deliberately: usage inspection must never mutate + * the credentials the real agent runs depend on, and a missing source must fail + * the run rather than be created as an empty directory that Agent Tank would + * report as "no usage" for a perfectly healthy account. + */ +function buildCredentialMountArgs(sources: BundledCredentialSource[]): string[] { + return sources.flatMap(source => ['--mount', [ + 'type=bind', + mountField('source', source.hostPath), + mountField('target', source.entry.configPath), + 'readonly', + ].join(',')]); +} + +/** + * Host paths the daemon refused because they do not exist. + * + * This is the namespace-correct existence check the backend cannot perform + * itself, read back out of the daemon's error so one unauthenticated agent + * drops out of the run instead of taking every other agent's usage with it. + */ +export function missingBindSources(stderr: string): string[] { + return [...stderr.matchAll(/bind source path does not exist:[ \t]*(.*?)\.?[ \t]*$/gm)] + .map(match => match[1]) + .filter(Boolean); +} + +/** + * ONE OF TWO PLACES that know the Agent Tank config file schema (the other is + * `parseBundledAgentTankOutput`). Verified against integry/agent-tank + * `src/agent-config.js`: each entry takes `provider` (claude | codex | agy), an + * optional `id`, and a `configPath` that is handed to the CLI as its config + * home (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GEMINI_CLI_HOME`). If upstream + * renames keys, change only this function. + * + * There is no place in this schema for the ProPR alias, which is why the alias + * of the account each entry was built from is tracked separately (see + * `BundledRunResult.aliases`) instead of being recovered from the output. + */ +export function buildBundledAgentTankConfig(entries: BundledAgentTankEntry[]): string { + return JSON.stringify({ + agents: entries.map(entry => ({ + provider: entry.provider, + // Pin the id to the provider key so the output map is keyed exactly + // like the HTTP `/status` response every downstream consumer parses. + id: entry.provider, + configPath: entry.configPath, + })), + // `--once` already skips the HTTP server; disabling Docker bridge + // detection stops Agent Tank from shelling out to a `docker` binary that + // deliberately does not exist inside the agent image. + dockerAccess: false, + }, null, 2); +} + +/** + * ONE OF TWO PLACES that know the Agent Tank output schema. Upstream + * `--once --json` prints `watcher.getStatus()`, a bare map keyed by agent id; + * we also accept an `{ agents: {...} }` envelope so a minor upstream wrapper + * change does not break the integration. + */ +export function parseBundledAgentTankOutput(stdout: string): Record { + const trimmed = stdout.trim(); + if (!trimmed) return {}; + // `--once --json` may be preceded by banner lines; start at the first brace + // rather than assuming the whole buffer parses. + const start = trimmed.indexOf('{'); + if (start < 0) return {}; + let parsed: Record; + try { + parsed = JSON.parse(trimmed.slice(start)) as Record; + } catch (error) { + logger.warn({ error: (error as Error).message }, 'Bundled Agent Tank produced unparseable JSON'); + return {}; + } + const source = (parsed.agents && typeof parsed.agents === 'object') + ? parsed.agents as Record + : parsed; + const agents: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const status = value as Partial; + agents[key] = { + name: typeof status.name === 'string' ? status.name : key, + usage: (status.usage && typeof status.usage === 'object') + ? status.usage as Record + : {}, + metadata: status.metadata, + lastUpdated: status.lastUpdated, + error: typeof status.error === 'string' ? status.error : null, + }; + } + return agents; +} + +/** + * Resolve the image to run Agent Tank in: the exact one the agent registry is + * already using, so bundled Agent Tank always matches the CLI versions the + * agents actually run with. + */ +async function resolveAgentImage(): Promise { + try { + const { AgentRegistry } = await import('../agents/AgentRegistry.js'); + const configured = AgentRegistry.getInstance().getAllAgents()[0]?.config.dockerImage; + if (configured) return configured; + } catch (error) { + logger.debug({ error: (error as Error).message }, + 'Agent registry unavailable for bundled Agent Tank; falling back to the configured image name'); + } + return process.env.AGENT_DOCKER_IMAGE || 'propr/agent:latest'; +} + +/** Build the credential sources for every eligible enabled agent. */ +async function collectBundledAgents(): Promise { + const agents = (await loadAgents()).filter(agent => agent.enabled); + const sources: BundledCredentialSource[] = []; + const seen = new Set(); + + for (const agent of agents) { + const provider = toAgentTankAgent(agent.type); + if (!BUNDLED_SUPPORTED_TANK_AGENTS.has(provider)) continue; + // Agent Tank tracks a provider, not a ProPR alias. If two aliases share a + // provider we can only report one; the first enabled one wins, matching + // how the sidebar already groups by provider. Which alias won is recorded + // on the entry so an alias-specific reader cannot mistake this account's + // usage for another account of the same provider. + if (seen.has(provider)) continue; + const hostPath = hostCredentialPath(agent); + const containerConfigPath = CONTAINER_CONFIG_PATHS[agent.type]; + if (!hostPath || !containerConfigPath) continue; + if (!credentialSourceIsUsable(agent, hostPath)) continue; + seen.add(provider); + sources.push({ + hostPath, + entry: { provider, alias: agent.alias, configPath: containerConfigPath }, + }); + } + + return sources; +} + +/** + * True when at least one enabled agent is an Agent Tank provider with readable + * credentials, i.e. when bundled mode would actually report something. Used by + * the detection banner so a fresh install with no usable agent is not nagged to + * enable a feature that would show an empty sidebar. + */ +export async function canRunBundledAgentTank(): Promise { + try { + return (await collectBundledAgents()).length > 0; + } catch (error) { + logger.debug({ error: (error as Error).message }, + 'Could not determine bundled Agent Tank eligibility'); + return false; + } +} + +async function runBundledAgentTank(): Promise { + try { + let sources = await collectBundledAgents(); + if (sources.length === 0) { + logger.debug('Bundled Agent Tank skipped: no enabled agent has a usable credential directory'); + return { agents: {}, aliases: {} }; + } + + const image = await resolveAgentImage(); + + // The daemon reports missing bind sources one run at a time and each + // retry drops at least one, so this many attempts is always enough. + const maxAttempts = sources.length; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const entries = sources.map(source => source.entry); + const aliases = Object.fromEntries(entries.map(entry => [entry.provider, entry.alias])); + + const result = await executeDockerCommand('docker', [ + 'run', '--rm', + // No inbound/outbound needs beyond the provider APIs the CLIs call; + // we do not add --network none because `/usage` for some providers + // hits the provider API. + '--name', `propr-agent-tank-${randomBytes(6).toString('hex')}`, + '-e', 'PROPR_AGENT_TYPE=agent-tank', + '-e', `${CONFIG_ENV_VAR}=${buildBundledAgentTankConfig(entries)}`, + ...buildCredentialMountArgs(sources), + image, + // `sh -c