The test AppConfig initializer in routes::auth listed pixelle_video twice
(a merge artifact from stacking the Gemini and comfy-cloud branches), which
made the lib test binary fail to compile with E0062 and blocked `cargo test
--lib` for the whole crate. Remove the duplicate; lib tests build again.
classify_tool_risk missed execute-class tools whose names use "run",
"terminal", "spawn" or "process" rather than bash/shell/exec/command — most
notably "RunInTerminal". Such a tool fell through to ReadOnly and would
therefore auto-allow without approval in Default/AcceptEdits mode, a real gap
in the permission gate. Broaden the Execute branch to catch them; over-
classifying as Execute only ever asks for more approval, so it is fail-safe.
This was already asserted by an existing unit test (classifies_execute_tools
expects RunInTerminal -> Execute); the test only became runnable after the
rebase onto refactor/rust-backend pulled in the AppConfig test-fixture repair
that had been blocking the lib test binary. Full agent_workbench unit suite
now 32/32; the four integration suites stay green.
The SDK's built-in Bash tool ran commands as a bare subprocess with only
current_dir set — no OS boundary. On a server that let an agent shell read
/etc/shadow, cd /, write anywhere the process uid could, and reach the
network; the phase-1 path validation only guards the file tools, not bash.
(The SDK's SandboxSettings is dead config: Agent::new never reads it.)
Replace it with SandboxedBashTool — same name and input schema, registered
via custom_tools so it overrides the built-in by name — that confines the
command with bubblewrap, gated by permission mode:
- Default / AcceptEdits (review modes): run inside a bwrap namespace with the
system mounted read-only, the session sandbox dir bind-mounted rw at its
real path and made the cwd, /etc //root //home and all other host paths
absent, and --unshare-all (network included). Filesystem escape and network
egress are blocked by the kernel, not by convention.
- FullAccess (explicitly dangerous): unconfined, same as before, but every
command is audit-logged with a warning.
Fail-closed: in a review mode, if bwrap is unavailable the command is refused
rather than silently run unconfined — no fabricated boundary.
Verified end-to-end in tests/agent_bash_sandbox.rs by invoking the tool:
under Default mode `cat /etc/passwd` sees no host passwd, `ls /` exposes no
etc//root//home, a write to /etc/cron.d/x fails (and the host file is never
created), and the network is unreachable; in-sandbox work succeeds and lands
in the real session dir; FullAccess can see the host. bwrap 0.11.2 confirmed
functional on this host (root, CAP_SYS_ADMIN, user namespaces enabled).
Phase-1 suites stay green.
validate_path_within_cwd used exists() to decide whether a path was new.
exists() follows symlinks, so a *dangling* symlink (target missing) reported
false and fell into the not-yet-existing branch, which only bound-checked the
nearest existing ancestor — never the symlink itself. An approved
`ln -s /etc/cron.d/pwn evil` inside the sandbox would therefore pass
validation, and a subsequent Write to `evil` would follow the link and create
a file outside the sandbox. In AcceptEdits mode this chained cleanly: approve
one innocuous-looking symlink (Execute), then writes auto-allow.
Resolve every path component with lstat (symlink_metadata, which does not
follow links) instead of exists():
- Final component: if it is a symlink (dangling or live), resolve its real
target via read_link + canonicalize-nearest-ancestor and reject unless the
target stays within the sandbox.
- Ancestor walk: an intermediate dangling symlink directory is likewise
resolved and bound-checked rather than skipped.
Live symlinks were already caught by canonicalize; this closes the dangling
case. It does not close the check-vs-open TOCTOU (the open happens inside the
SDK tool and would need O_NOFOLLOW/openat2 there); tracked for phase 2 along
with OS-level bash isolation.
Covered by tests/agent_sandbox.rs (dangling symlink to absolute and relative
targets, write tool through a dangling link, write through an intermediate
dangling symlink dir, all rejected; dangling link pointing inside and ordinary
new files still allowed) plus mirrored in-crate unit tests.
The previous approval registry used a oneshot channel, so two independent
signals decided an approval: the HTTP handler's result (from Sender::send's
bool) and the agent loop's allow/deny (from which timeout arm fired). At the
300s boundary these could disagree — a late /approve could see send() return
Ok (receiver not yet dropped) and report ok:true to the user, while the
timeout's final poll simultaneously fired and denied the tool. The frontend
was told "approved" while nothing ran. The mirror case (ok:false but allowed)
was impossible, so it never executed an unapproved tool, but the user-facing
signal could lie.
Replace the channel with a single source of truth: each entry is an
AtomicU8 state machine (Pending -> Approved/Denied/TimedOut) plus a Notify.
The decision is made by exactly one compare_exchange from Pending — whoever
wins (approver or timeout) settles it, everyone else observes a losing CAS
and no-ops. resolve() now returns whether *its* CAS won, so /approve tells
the frontend the truth; the loop decides purely from the settled atomic.
They read the same atomic and cannot disagree.
A PendingGuard removes the registry entry on drop (normal completion or
aborted/disconnected session), so there is no leak on any path. The callback
also emits an approval_resolved SSE event with the final decision so a late
click — already told ok:false — can dismiss its modal with the real outcome.
Verified with an integration test that races resolve() against a near-zero
timeout 5000x and asserts ok==true iff the executed decision is Approved
(never a mixed state). Run via tests/ because the in-crate lib test binary
is currently blocked by unrelated AppConfig test fixtures in other modules.
A minimal chat UI for the server-side agent, wired to the SSE engine:
- Streams assistant text incrementally and renders each tool call as a card
showing the tool name, input and (color-coded) output or denial reason.
- Surfaces interactive approval prompts: when the agent requests a risky
tool, a modal shows the tool and its arguments and posts the user's
decision to /api/agent/approve, unblocking the loop.
- Permission-mode selector (standard review / accept edits / full access),
new-session control, and a live session usage bar (turns, tokens, cost).
Registered as /agent-workbench (admin) and /user/agent-workbench (user) in
the SPA router and sidebar. Typecheck and vite build pass.
Adds the Agent Workbench HTTP surface wiring the open-agent-sdk loop into
BoosAPI:
- POST /api/agent/chat (SSE): runs an agentic turn and streams session /
text / thinking / tool_use / tool_result / approval_request / result /
error / done events. The agent's LLM calls are routed back through
BoosAPI's own OpenAI-compatible /v1 using the built-in downstream key
(constructed via the SDK's OpenAIProvider), so any aggregated model works
and usage flows through the normal accounting pipeline.
- POST /api/agent/approve: resolves a pending tool approval by request id.
- GET /api/agent/status: engine info + a session's accounting.
Safety is enforced in the SDK permission callback: every tool first passes
sandbox path validation (no escaping the per-session working dir), then the
risk/mode matrix — writes and command execution require an interactive
approval round-trip (default mode) that blocks the loop until the user
answers or the request times out (300s -> deny). Each session runs in its
own data/agent_sessions/{id}/ sandbox and turn usage is persisted.
Registered in the API router and added open-agent-sdk as a path dependency.
Adds the agent_sessions table (migration) and a session_store module that
owns its lifecycle:
- ensure_session: idempotently creates the session row on first chat.
- record_turn: accumulates input/output/total tokens, cost and turn count
after each completed turn, bumping updated_at.
- get_session: user-scoped fetch so a tenant can only read its own session.
Backed by SQLite via SQLx, matching the existing service style. Unit-tested
against an in-memory database (idempotency, accumulation, user scoping).
Introduces the SDK-free building blocks for a server-side AI agent:
- permission: tool risk classification (ReadOnly/Write/Execute) and the
per-mode decision matrix (Default/AcceptEdits/FullAccess). Default mode
auto-allows reads and requires approval for writes/executes.
- sandbox: per-session isolated working directory under
data/agent_sessions/{id}/ plus path validation that confines every tool
path argument within the session directory (rejects ../, absolute and
symlink escapes), mirroring the AQBot security model.
- approval: process-global registry bridging the SSE approval round-trip via
oneshot channels, so the permission callback can block until the user
approves or denies a risky tool.
All three modules are unit-tested in isolation (17 tests) and carry no
dependency on the agent SDK; the HTTP layer maps their results onto the
SDK permission callback in a later commit.
Implement the four Gemini native inbound endpoints so Gemini CLI / Google
GenAI SDKs can point their base URL at the gateway:
- GET /v1beta/models — directory listing in Gemini shape, built from
configured routes (like /v1/models); no upstream call, no billing.
- POST /v1beta/models/{model}:generateContent — non-streaming, routed
through ProxyConductor with failover + usage_logs + billing.
- POST /v1beta/models/{model}:streamGenerateContent — SSE passthrough via
open_stream_with_failover + sse_passthrough_response.
- POST /v1beta/models/{model}:countTokens — metadata call, routed for a
channel but not billed.
Auth accepts the gateway key from ?key= or x-goog-api-key (Gemini
convention) and reuses the downstream-key authorization + limits.
Billing closes the loop: gemini_extract_usage reads usageMetadata
(promptTokenCount / candidatesTokenCount) for non-streaming, and
gemini_stream_usage_from_line feeds the shared StreamUsageAccumulator so
streamed usageMetadata is billed too.
Unit tests cover model:action parsing, usage extraction, the stream usage
parser, and query forwarding (key/alt dropped).
Add Gemini-shaped upstream calls that mirror AQBot's native passthrough
and BoosAPI's /v1/responses forwarding: the model lives in the URL path
(/v1beta/models/{model}:{op}) and the key in ?key=, so the body is sent
verbatim with no model rewrite.
- gemini_base_url / gemini_operation_url normalize the v1beta root and
build {base}/v1beta/models/{model}:{op}?key=...&{extra}, stripping a
leading models/ prefix and handling the no-key case.
- execute_gemini_operation runs non-streaming ops (generateContent /
countTokens) and returns upstream JSON verbatim.
- execute_gemini_stream_response opens streamGenerateContent for SSE
passthrough, enforcing alt=sse via merge_alt_sse.
Unit tests cover URL building and alt=sse merging.
Pre-existing breakage on this branch (introduced by the billing commits),
unrelated to the Gemini work but blocking both `cargo build` and
`cargo test`:
- admin/users.rs imported routes::auth::DEFAULT_NEW_USER_MAX_COST_USD,
which was never defined. Add it to auth.rs as $1.0 (the billing design's
"new users default to $1" starter allowance).
- AppConfig gained `payment` and `pixelle_video` fields but several test
helpers still used struct literals missing them. Initialize both with
Default::default() at each affected test site.
Add GET /api/cli-profiles/templates returning copy-ready config snippets
for pointing common AI clients at this gateway: Cursor, VS Code Continue,
Claude Code, and a generic OpenAI-compatible blob.
Ported from AQBot's list_gateway_templates (a desktop app that hardcodes
localhost:8080). Adapted for server-side use: the gateway origin is resolved
per request — ?base_url= override, then X-Forwarded-Proto + Host (correct
behind a TLS-terminating proxy), then config server.host:port fallback
(0.0.0.0/:: rewritten to 127.0.0.1, matching the comfyui-agent local-base
convention). OpenAI-compatible clients get the /v1 base; Claude Code gets
the bare origin since it appends /v1/messages itself.
Scope guard: this only returns config strings + copy hints. It deliberately
does not rewrite local CLI config files (AQBot's desktop cli_config behavior),
which has no meaning on a server.
New module proxy::cli_profiles::templates holds the snippet content; the admin
handler resolves the origin. Covered by 9 unit tests.