Onboard Jimeng (火山视觉) as a `platform = "jimeng"` entry in the existing
sites/accounts console, reusing the CRUD/list/status UI while branching every
OpenAI-specific path so no other platform is affected. Jimeng is not
OpenAI-compatible (Volcengine V4 HMAC signing, async two-step generation, no
`/models` endpoint), so each branch keys off the single source of truth
`routes::admin::accounts::is_jimeng_platform` (case-insensitive).
Backend (src/routes/admin, src/services/jimeng):
- create_account → create_jimeng_account: AK lands in accounts.access_token; SK
+ region/host/sessionToken nest under extra_config.jimeng.*; credentialMode
forced to "apikey". No spawn_route_rebuild — Jimeng never enters the route
table.
- Secret protection: SK/sessionToken masked with •••• on list/get/update
responses (mask_account_extra_config); update_account rejects a masked
extra_config write-back so an edit round-trip can't clobber the real secret.
- verify_token → verify_jimeng_token: signs a sentinel GetResult probe via
JimengProvider::from_credentials(...).verify(). The Volcengine gateway checks
the V4 signature before the business layer, so a valid AK/SK is Authenticated
(business layer reports "not found") and a bad one yields an auth-class Code
(classify_verify_response / is_auth_error_code). Never calls /models, never
triggers generation.
- build_probe_plan short-circuits Jimeng (probe-now/-stream) with a BadRequest
pointing operators at the connection "验证" flow.
- runtimeHealth: Jimeng has no proxy_logs, so derive_jimeng_runtime_health reads
the latest jimeng background_tasks row; account_response_value_with_jimeng
applies it only when the site is Jimeng and ignores the override otherwise.
Frontend (src/web):
- Sites.tsx: add jimeng platform option; prefill visual.volcengineapi.com;
hide URL pool + auto-probe sections.
- Accounts.tsx: AK + SK(required) + region/host/session fields under the API Key
segment; "验证 AK/SK 签名" gates add; skip model sync / route rebuild.
- api.ts: verify-token payload carries secretKey/region/host/sessionToken.
Tests: unit coverage for credential nesting, secret masking, verify-response
classification, and regression guards proving non-Jimeng accounts ignore the
Jimeng health override and keep their proxy-log-derived health.
Also: stop tracking the stray root package-lock.json (repo installs via pnpm)
and gitignore it.
Exercise the decision logic and async driver beyond the happy path:
- decide() consults the business code before data.status (fatal and
retryable codes win over a bogus done status)
- not_found is terminal
- exponential backoff grows 1,2,4,8 and caps at 8x the interval
- backoff multiplier resets after a normal generating wait
- a retryable code followed by a fatal one stops the loop
Round out request-body validation and pin the polling parameters:
- all five allowed aspect ratios and both frame counts build
- prompt + image_urls together both land in the video body
- whitespace-only prompt is dropped but an image still satisfies one-of
- optional video fields are omitted when absent
- default poll interval/timeout values (image 2s/120s, video 8s/600s)
Add unit tests for error-code classification boundaries and response
decoding that the existing cases left open:
- 50516/50517 are capability-independent (retryable for image too)
- adjacent copyright codes 50518 (fatal) vs 50519 (retryable)
- full 50520..=50522 service-exception range is fatal
- 50500/50501 fatal-for-image / retryable-for-video via classify directly
- negative codes are fatal (no poll loop)
- base64 image output and absent (not just null) data field decode safely
A freshly registered user's auto-provisioned default downstream key must
start with a zero spend allowance, not unlimited. Two parts:
- DEFAULT_NEW_USER_MAX_COST_USD changes from 1.0 to 0.0, so new users have no
free spend until they top up or an admin raises the cap.
- The constant's docs now spell out the failure mode it guards: a NULL
max_cost is treated as *unlimited* by the proxy, so the column must always
be bound on INSERT. The registration path (routes::auth::register) and the
admin create-user path (routes::admin::users) both already include the
max_cost column and bind this constant, so both now persist 0.0 rather than
leaving NULL.
Tests (cargo test --lib routes::auth::tests, 10 passed): assert the constant
is 0.0, and run the registration key INSERT against an in-memory DB to verify
the stored max_cost is non-NULL and exactly 0.0. The agent suites
(approval/sandbox/bash_sandbox/toctou) stay green.
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.
Point the project-structure section at desktop/, clarify it is an
independent Cargo workspace (excluded from the backend crate), and link
to desktop/README.md for build steps.
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.
desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.
Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
The desktop/ subproject (added next) carries its own node_modules,
dist, and Cargo target dirs once built. Ignore them from the metapi
root so the 6.5G+ of generated output never enters the repo.
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 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).