The file tools (Read/Write/Edit) open paths inside the SDK process after the
can_use_tool callback validates them, leaving a check-vs-open window. Fully
closing it atomically needs openat2(RESOLVE_BENEATH) file tools replacing the
SDK's in-process ones (tracked as follow-up; the Edit tool's file-state-cache
contract makes that a non-trivial reimplementation).
This test pins the fact that makes the window unreachable under the current
model: an exploit needs a concurrent mutator, the only agent-controlled source
of concurrency is a backgrounded process, and bwrap's PID namespace
(--unshare-all) makes the bash command PID 1 — so the kernel reaps any
backgrounded job when the single bash call exits. The test backgrounds a writer
that would keep appending for ~2s and asserts its output stops the moment the
call returns. With sequential tool execution and per-session bind isolation,
no surviving concurrent mutator exists to win the race.
This is an honest mitigation with evidence, not a claim that the in-process
open is atomic.
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.
digital_human.rs uses url::Url::parse but the crate was only a transitive
dependency, not declared in Cargo.toml, so the lib failed to compile with
E0433. Add the explicit dependency (url 2.x was already in Cargo.lock).
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.