feat: control panel web UI with axum API + React dashboard#180
Conversation
📝 WalkthroughWalkthroughThis PR introduces a comprehensive control panel for ZeptoClaw comprising an Axum-based REST/WebSocket API server with authentication and CSRF middleware, a React+TypeScript frontend SPA with real-time monitoring dashboards, kanban task board, conversation sessions viewer, and agent office visualization, plus integration of a TaskTool for kanban operations and environment configuration extensions. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Frontend (React)
participant API as API Server (Axum)
participant Auth as Auth Module
participant EventBus as EventBus
participant Storage as Task Store
Client->>API: POST /api/auth/login (password)
API->>Auth: verify_password(password, hash)
Auth-->>API: ✓ verified
API->>Auth: generate_jwt(username, secret)
Auth-->>API: token
API-->>Client: 200 { token }
Client->>Client: localStorage.panel_token = token
Client->>API: GET /ws/events?auth=token
API->>API: validate query token
API-->>Client: 101 Upgrade WebSocket
Client->>API: [Connected]
Note over API,EventBus: AgentLoop emits events
API->>EventBus: send(PanelEvent::ToolStarted)
EventBus->>API: [broadcast to subscribers]
API->>Client: { type: "tool_started", tool: "..." }
Client->>Client: Update UI (live agent desk)
sequenceDiagram
participant User as User / Browser
participant Client as React App
participant API as API Server
participant Store as TaskStore (JSON)
User->>Client: Opens /kanban page
Client->>API: GET /api/tasks
API->>Store: list()
Store-->>API: [{ id, title, column, ... }]
API-->>Client: JSON tasks
Client->>Client: Render kanban board
User->>Client: Drags card to "In Progress"
Client->>Client: Optimistic UI update
Client->>API: POST /api/tasks/{id}/move { column: "in_progress" }
API->>Store: move_task(id, column)
Store->>Store: Update + save to JSON
Store-->>API: ✓ OK
API-->>Client: 200 OK
Client->>Client: Confirm UI state
alt Move fails
API-->>Client: 404/422 error
Client->>Client: Rollback optimistic update
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 19
🧹 Nitpick comments (7)
panel/src/components/Sidebar.tsx (1)
56-58: Consider deriving version from a single source of truth.The hardcoded version string
"v0.6.x"may become stale as the project evolves. Consider importing the version frompackage.jsonor an environment variable set at build time to keep it synchronized.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@panel/src/components/Sidebar.tsx` around lines 56 - 58, Replace the hardcoded version string in Sidebar.tsx ("v0.6.x") with a build-time single source of truth: import the version from package.json (e.g., using import { version } from '../../package.json') or read a VERSION env var injected at build time, then render that variable in the JSX inside the same div; update any tests or type declarations as needed to allow the import (or declare process.env.VERSION) so the displayed version stays synchronized.src/api/routes/channels.rs (1)
57-60: Simplify response construction (remove redundant JSON round-trip).
json!already returns aValue; converting viaserde_json::to_valueand fallback here is unnecessary.♻️ Suggested patch
- Json( - serde_json::to_value(json!({ "channels": channels })) - .unwrap_or_else(|_| json!({ "channels": [] })), - ) + Json(json!({ "channels": channels }))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/routes/channels.rs` around lines 57 - 60, The response construction is doing an unnecessary serde_json::to_value round-trip; replace the Json(serde_json::to_value(json!({ "channels": channels })).unwrap_or_else(...)) pattern with a direct Json(json!({ "channels": channels })) return using the existing channels variable (the surrounding function that returns Json). Ensure the returned value type still satisfies the handler's return type (axum::Json or equivalent) and remove the unwrap_or_else fallback since json! already produces a serde_json::Value.src/tools/task.rs (1)
107-122: Consider truncating large task lists.Per coding guidelines, tool results should be truncated to 50KB before sending to LLM. For the
listaction with many tasks, the JSON output could exceed this limit.Suggested truncation for list output
"list" => { let col = args.get("column").and_then(|v| v.as_str()); let tasks = self.store.list(col).await; if tasks.is_empty() { match col { Some(c) => format!("No tasks in column '{c}'"), None => "No tasks found".to_string(), } } else { - serde_json::to_string_pretty(&tasks).unwrap_or_default() + let json = serde_json::to_string_pretty(&tasks).unwrap_or_default(); + if json.len() > 50_000 { + format!("Found {} tasks (output truncated). Use column filter to narrow results.", tasks.len()) + } else { + json + } } }As per coding guidelines: "Sanitize tool results before sending to LLM: strip base64 URIs, hex blobs, and truncate to 50KB."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools/task.rs` around lines 107 - 122, The list action in async fn execute is returning the full serde_json::to_string_pretty(&tasks) which can exceed the 50KB tool-result limit; after calling self.store.list and serializing with serde_json::to_string_pretty, sanitize the resulting JSON string by stripping base64 data URIs and long hex/blob substrings, then if the string length > 50*1024 bytes truncate it (e.g., keep the first 50KB minus room for a suffix) and append a clear "(truncated)" notice; update the code paths in execute that return the list output (the "list" match arm) to perform this sanitize-and-truncate step before returning.panel/README.md (1)
1-73: Replace generic Vite template README with ZeptoClaw panel documentation.This is the default Vite + React template README. Consider replacing it with documentation specific to the ZeptoClaw control panel, covering:
- How to start the panel (
zeptoclaw panel start)- Available pages (Dashboard, Logs, Sessions, Kanban, Agents)
- Authentication setup
- Development workflow (
npm run dev)As per coding guidelines: "When adding new commands or workflows, include a runnable example in documentation."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@panel/README.md` around lines 1 - 73, Replace the generic Vite README content in README.md with ZeptoClaw panel-specific documentation: add a "Getting Started" section showing the runnable command "zeptoclaw panel start", a "Development" section with the dev workflow and example "npm run dev", an "Available Pages" section listing Dashboard, Logs, Sessions, Kanban, and Agents, and an "Authentication" section describing setup and creds; ensure you include at least one runnable example for new commands (per guidelines) and keep instructions concise and actionable so contributors can start and develop the panel immediately.panel/src/pages/Logs.tsx (1)
39-48: Note:tool_failedappears in both 'tool' and 'error' filter views.The
getEventCategoryfunction returns'tool'fortool_failed(via thetool_prefix check on line 41), but the filter logic at lines 173-178 also includestool_failedin the error filter. This means failed tool events show in both categories, which may be intentional for discoverability.If you want
tool_failedto appear only in the error filter, move the error check before thetool_prefix check:Optional: Prioritize error classification
function getEventCategory(type: string): FilterCategory { + if (type === 'tool_failed' || type.includes('error') || type.includes('fail')) return 'error' if (type.startsWith('tool_')) return 'tool' if (type.startsWith('agent_')) return 'agent' if (type === 'channel_status') return 'channel' if (type === 'cron_fired') return 'cron' - if (type === 'tool_failed' || type.includes('error') || type.includes('fail')) return 'error' if (type === 'message_received') return 'channel' return 'all' }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@panel/src/pages/Logs.tsx` around lines 39 - 48, The getEventCategory function currently classifies "tool_failed" as 'tool' due to the early type.startsWith('tool_') check, causing duplicate visibility with the 'error' filter; to make "tool_failed" appear only in the 'error' category, adjust the classification order in getEventCategory so the error-specific checks (e.g., type === 'tool_failed' or checks for 'error'/'fail') run before the type.startsWith('tool_') branch, ensuring "tool_failed" is returned as 'error' and not 'tool'.src/api/routes/routines.rs (1)
11-42: Stub implementations — track for completion.All route handlers return hardcoded stub responses without actual business logic:
list_routines: Always returns empty arraycreate_routine: Always returns{"id": "stub"}toggle_routine: Always returns{"enabled": true}This is fine for initial frontend development, but these need real implementations wired to
RoutineStoreper the project's routine management patterns.Would you like me to open an issue to track implementing real routine persistence and CRUD operations? Based on learnings, this should integrate with
RoutineStorefor JSON file persistence with cooldown enforcement.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/routes/routines.rs` around lines 11 - 42, Replace the stub handlers with real implementations that call into the application's RoutineStore on AppState: in list_routines call the RoutineStore list method and return the routines JSON; in create_routine deserialize Json<Value> and call RoutineStore.create (return CREATED and the new id or proper error); in update_routine call RoutineStore.update with the Path id and request body and return OK or appropriate error; in delete_routine call RoutineStore.delete and return NO_CONTENT on success; in toggle_routine call RoutineStore.toggle (or read/update the Routine enabled flag) for the Path id and return the updated enabled state as JSON. Ensure you obtain the RoutineStore from the provided State(Arc<AppState>), propagate and log errors as HTTP error status codes, persist to the JSON-backed store and respect existing cooldown enforcement logic in RoutineStore.src/api/tasks.rs (1)
188-242: String-based errors make upstream HTTP mapping brittle.Returning
Result<_, String>across create/update/move/delete forces route handlers to infer semantics via message text. Introduce a typedTaskStoreErrorenum (NotFound,InvalidColumn,Persistence, etc.) so API routes can map statuses reliably without string parsing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/tasks.rs` around lines 188 - 242, The current methods update, move_task, delete (and any create/persistence code calling self.save()) return Result<_, String>, causing fragile string-based error handling; define a TaskStoreError enum (variants like NotFound(id: String), InvalidColumn(column: String), Persistence(source: anyhow::Error) or similar) and change signatures of update, move_task, delete (and save) to return Result<(), TaskStoreError>; update error construction sites that now call ok_or_else(|| format!("Task not found: {id}")) to return Err(TaskStoreError::NotFound(id.to_string())), change the COLUMNS check in move_task to return Err(TaskStoreError::InvalidColumn(column.to_string())), and adapt save() to map IO/serialization failures into TaskStoreError::Persistence so route handlers can match on TaskStoreError instead of parsing messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@panel/src/App.css`:
- Around line 1-42: Delete the unused App.css file (the Vite starter styles
shown) from the repo and ensure there are no remaining imports of "App.css"
anywhere in the codebase (search for the filename in files like App.jsx /
main.jsx); if any import exists, remove it so Tailwind via index.css remains the
single source of styles and no build references to App.css remain.
In `@panel/src/App.tsx`:
- Around line 15-33: The App component currently reads isAuthenticated from its
useAuth while Login uses a different auth instance, so bumping authVersion only
re-renders and may leave the gate locked; fix by centralizing auth state: either
hoist the login/token state into App and pass the login handler and token setter
into <Login /> (replace the onSuccess callback with a call that updates the
shared token/login state via setAuth or setAuthVersion and token setter), or
ensure both App and Login use the same auth context/provider instance (use the
same useAuth provider wrapping App and Login) so that calling the Login login
method updates isAuthenticated observed by App; update references to useAuth,
Login, authVersion and setAuthVersion accordingly so the sign-in action mutates
the single shared auth source.
In `@panel/src/components/AgentDesk.tsx`:
- Around line 90-103: The function formatRelativeTime currently uses new
Date(iso).getTime() which can yield NaN (and so produce "NaNd ago") without
throwing; update formatRelativeTime to detect invalid dates by checking
isNaN(timestamp) after const timestamp = new Date(iso).getTime() and handle that
case (for example return the original iso or a safe fallback like 'unknown' or
'just now') before computing diff/secs; touch the function body where diff,
secs, mins, hours are computed to use the validated timestamp and keep the
existing try/catch for any other unexpected errors.
In `@panel/src/components/KanbanCard.tsx`:
- Around line 97-100: The label pills in KanbanCard use unstable keys
(key={label}) which can collide for duplicate labels; update the map in
KanbanCard where task.labels.slice(0, 3).map(...) to use a stable unique key by
using the map index or a label identifier: e.g., change the callback signature
to .map((label, i) => ...) and set key={`label-${i}-${label}`} or, if label
objects have an id, use key={label.id}; ensure the chosen symbol is applied to
the span rendering each pill to avoid reconciliation issues.
In `@panel/src/hooks/useWebSocket.ts`:
- Around line 20-24: The WebSocket connection in the connect function of the
useWebSocket hook is opened without auth and never reads auth from useAuth;
update the hook to import/use useAuth, retrieve the current bearer token (e.g.,
const { token } = useAuth()), and propagate it when creating the WebSocket (for
browser WS you can include the token in the connection URL as a query param or
via Sec-WebSocket-Protocol, e.g., new
WebSocket(`${protocol}//${window.location.host}/ws/events?auth=${encodeURIComponent(token)}`)
or new WebSocket(url, `Bearer ${token}`)); apply the same change to the
reconnection logic (the later WebSocket creation around lines 53-61) and ensure
wsRef.current is still assigned and connection fails gracefully if token is
missing.
In `@panel/src/pages/CronRoutines.tsx`:
- Around line 418-451: The mutating hooks (createMut, updateMut, deleteMut,
triggerMut) call apiFetch without the required X-CSRF-Token header, causing
403s; update the API client usage so apiFetch automatically includes the CSRF
token for mutating requests (POST/PUT/DELETE) by reading the token (e.g., from
the CSRF cookie or existing helper) and adding an "X-CSRF-Token" header to the
request, or modify apiFetch itself to inject the header when method !== 'GET';
update all places that call apiFetch for writes (createMut, updateMut,
deleteMut, triggerMut and the other occurrences noted) to rely on the client
behavior so the token is sent with every protected mutating request.
In `@src/api/middleware.rs`:
- Around line 59-69: The CSRF check uses a non-cryptographic csrf_hash and
accepts future timestamps; replace this with a proper cryptographic MAC (e.g.,
HMAC-SHA256) tied to the secret and timestamp and verify it using a
constant-time comparison, reject tokens whose timestamp is outside an allowed
window (e.g., older than 1 hour or more than a small future skew like 5
minutes), and update the validation flow that currently computes expected_hash
and compares format!("{expected_hash:016x}") == hash_str to instead compute an
HMAC over the timestamp (or a compact encoded HMAC+timestamp token), parse the
timestamp safely (reject non-numeric), check the timestamp range (deny future
timestamps beyond skew), and verify the HMAC using a constant-time equality
function in the functions that reference csrf_hash, timestamp, hash_str, and
secret.
In `@src/api/routes/channels.rs`:
- Around line 28-30: The CHANNEL_NAMES constant currently lists exact channel
names and omits valid families like "whatsapp_cloud", "feishu", "qq",
"dingtalk", "maixcam"; update the filtering logic that uses CHANNEL_NAMES (and
the same logic around the other occurrence at lines 40-44) to treat entries as
prefixes or perform starts_with checks instead of exact equality so health
checks for channels with these suffixes are included; in practice replace
exact-name matching with prefix matching (e.g., comparing
channel_id.starts_with(prefix)) or broaden CHANNEL_NAMES to include the missing
family prefixes so functions referencing CHANNEL_NAMES will return those health
checks.
In `@src/api/routes/cron.rs`:
- Around line 11-42: The cron route handlers (list_jobs, create_job, update_job,
delete_job, trigger_job) are stubs that ignore State<Arc<AppState>> and request
inputs; replace the stubs with real implementations that use the shared AppState
to read/write jobs and interact with the scheduler/db: list_jobs should fetch
jobs from AppState (e.g., state.job_store or state.db) and return them as JSON,
create_job should validate and deserialize Json<Value> into your Job model,
persist it via AppState (return StatusCode::CREATED with the new job id),
update_job should parse Path<String> id and Json<Value>, validate and update the
persisted job in AppState returning appropriate StatusCode, delete_job should
remove the job by id from AppState and return NO_CONTENT or NOT_FOUND, and
trigger_job should call the scheduler method on AppState (e.g.,
state.scheduler.trigger_job(id)) and return ACCEPTED or an error if missing;
ensure you handle and propagate errors (returning proper status codes) and do
not ignore the provided id/body parameters.
In `@src/api/routes/tasks.rs`:
- Around line 24-26: The handlers currently treat state.task_store == None as a
success-path stub (returning created/ok JSON) which hides dropped mutations;
change each branch that checks state.task_store (the let Some(ref store) =
state.task_store else { ... } blocks in the create/update/delete/move handlers)
to return StatusCode::SERVICE_UNAVAILABLE (503) with an explanatory JSON error
instead of a success response, and propagate/log an error; additionally,
consider moving this validation to startup (failing fast when task routes are
enabled but TaskStore is None) so TaskStore presence is guaranteed for functions
that use state.task_store.
In `@src/api/server.rs`:
- Around line 160-168: The WS events endpoint is currently reachable without
authentication because the auth_middleware skips paths starting with "/ws/";
update the routing/middleware so the websocket handshake goes through JWT/Bearer
validation: remove or tighten the "/ws/" bypass in auth_middleware (the skip
logic that checks path prefix) or alternatively register /ws/events behind the
existing auth layer by ensuring the
axum_mw::from_fn_with_state(shared_state.clone(),
super::middleware::auth_middleware) applies to the route; make auth_middleware
perform Bearer token + JWT validation (and CSRF checks if applicable) during the
WebSocket handshake so ws_events receives only authenticated connections.
- Around line 88-93: The CORS origin is hardcoded to "http://localhost:9092";
replace this with a runtime-built origin using PanelConfig (e.g.,
format!("http://localhost:{}", panel_config.port) or include host if present)
and pass that string into AllowOrigin::exact after parsing to HeaderValue (the
same parse::<axum::http::HeaderValue>().expect(...) pattern). Update the
CorsLayer construction (where CorsLayer::new() and AllowOrigin::exact are used)
to use the derived origin from PanelConfig (or use AllowOrigin::list if you need
to support multiple origins) so the allowed origin reflects the configured panel
port at runtime.
In `@src/api/tasks.rs`:
- Around line 111-129: The save() method currently writes directly to the target
file (tokio::fs::write), which is non-atomic; change it to perform an atomic
replace by writing the JSON data to a temporary file in the same directory
(e.g., derive temp name from self.path), fsync the temp file and its parent
directory if possible, then atomically rename the temp file to self.path
(tokio::fs::rename) after successful write; ensure create_dir_all(parent) still
runs first and propagate errors as before, and keep the stable ordering logic
using tasks.read() and serde_json::to_string_pretty unchanged.
- Around line 89-95: The synchronous blocking call path.exists() inside the
async method load should be replaced with an async-safe check: call
tokio::fs::metadata(path).await and use .is_ok() to determine existence; update
the conditional in Task::load (the load method using the local variable path) to
use tokio::fs::metadata(path).await.is_ok() instead of path.exists(), and ensure
tokio::fs is imported or referenced so the method remains fully
async-compatible.
In `@src/cli/panel.rs`:
- Around line 367-375: The PanelAuthAction handlers currently print TODO
messages but still return Ok(()) which incorrectly signals success; update the
match arms for PanelAuthAction::Mode { mode } and PanelAuthAction::ResetPassword
in the function handling these actions so that Mode persists the selected mode
to the panel config (e.g., update the config struct/store and save) and returns
Ok(()) only on successful persistence, and implement ResetPassword to prompt for
the new password, bcrypt-hash it, store the hash in the panel config, and return
Ok(()) only on successful hashing and persistence; if any step fails, return an
appropriate Err detailing the failure instead of Ok(()).
- Around line 190-194: The printed Panel UI URL uses panel_config.port but the
server is actually started with panel_config.api_port (see start_server(...)),
so update the println to reference panel_config.api_port (or print both ports
clearly) so the displayed URL matches the port passed to start_server; locate
the print block that references panel_config.port and replace it with
panel_config.api_port (or format a message showing panel_config.port and
panel_config.api_port) to ensure the URL is correct when ports differ.
- Around line 208-212: The --download branch in src/cli/panel.rs currently
prints a TODO and then falls through to the success path, misleading callers;
update the download handling so it does not report success when unimplemented:
inside the block that checks the download boolean (the `if download { ... }`
branch) either return an Err (e.g., using anyhow::anyhow) or exit non-zero
instead of continuing, ensuring the function never reaches the "Panel installed
successfully!" end-of-function print; reference the `download` variable and the
success message ("Panel installed successfully!") so you change only that
branch's control flow to fail fast with a clear error message about the
unimplemented download flow.
- Around line 183-185: The cmd_start path currently constructs AppState via
AppState::new(api_token.clone(), event_bus) leaving task_store unset;
instantiate the appropriate TaskStore (e.g., a persistent store used
elsewhere—TaskStore::new(...) or the SQL/DB-backed store your app provides)
inside cmd_start and pass it into AppState (or use AppState::with_task_store /
builder) so AppState.task_store is populated instead of None; update the call
site that creates state (replace AppState::new(...) with the form that accepts
the TaskStore or call the setter) so task routes use the real persisted
TaskStore.
In `@src/config/mod.rs`:
- Around line 216-218: The boolean parsing for ZEPTOCLAW_PANEL_ENABLED is too
strict (only matches lowercase "true") and should match other env-override
behavior; update the handler that sets self.panel.enabled to accept
val.eq_ignore_ascii_case("true") || val == "1" (or the equivalent pattern used
elsewhere in this module) so values like "TRUE" or "1" are recognized
consistently with other env flags.
---
Nitpick comments:
In `@panel/README.md`:
- Around line 1-73: Replace the generic Vite README content in README.md with
ZeptoClaw panel-specific documentation: add a "Getting Started" section showing
the runnable command "zeptoclaw panel start", a "Development" section with the
dev workflow and example "npm run dev", an "Available Pages" section listing
Dashboard, Logs, Sessions, Kanban, and Agents, and an "Authentication" section
describing setup and creds; ensure you include at least one runnable example for
new commands (per guidelines) and keep instructions concise and actionable so
contributors can start and develop the panel immediately.
In `@panel/src/components/Sidebar.tsx`:
- Around line 56-58: Replace the hardcoded version string in Sidebar.tsx
("v0.6.x") with a build-time single source of truth: import the version from
package.json (e.g., using import { version } from '../../package.json') or read
a VERSION env var injected at build time, then render that variable in the JSX
inside the same div; update any tests or type declarations as needed to allow
the import (or declare process.env.VERSION) so the displayed version stays
synchronized.
In `@panel/src/pages/Logs.tsx`:
- Around line 39-48: The getEventCategory function currently classifies
"tool_failed" as 'tool' due to the early type.startsWith('tool_') check, causing
duplicate visibility with the 'error' filter; to make "tool_failed" appear only
in the 'error' category, adjust the classification order in getEventCategory so
the error-specific checks (e.g., type === 'tool_failed' or checks for
'error'/'fail') run before the type.startsWith('tool_') branch, ensuring
"tool_failed" is returned as 'error' and not 'tool'.
In `@src/api/routes/channels.rs`:
- Around line 57-60: The response construction is doing an unnecessary
serde_json::to_value round-trip; replace the Json(serde_json::to_value(json!({
"channels": channels })).unwrap_or_else(...)) pattern with a direct Json(json!({
"channels": channels })) return using the existing channels variable (the
surrounding function that returns Json). Ensure the returned value type still
satisfies the handler's return type (axum::Json or equivalent) and remove the
unwrap_or_else fallback since json! already produces a serde_json::Value.
In `@src/api/routes/routines.rs`:
- Around line 11-42: Replace the stub handlers with real implementations that
call into the application's RoutineStore on AppState: in list_routines call the
RoutineStore list method and return the routines JSON; in create_routine
deserialize Json<Value> and call RoutineStore.create (return CREATED and the new
id or proper error); in update_routine call RoutineStore.update with the Path id
and request body and return OK or appropriate error; in delete_routine call
RoutineStore.delete and return NO_CONTENT on success; in toggle_routine call
RoutineStore.toggle (or read/update the Routine enabled flag) for the Path id
and return the updated enabled state as JSON. Ensure you obtain the RoutineStore
from the provided State(Arc<AppState>), propagate and log errors as HTTP error
status codes, persist to the JSON-backed store and respect existing cooldown
enforcement logic in RoutineStore.
In `@src/api/tasks.rs`:
- Around line 188-242: The current methods update, move_task, delete (and any
create/persistence code calling self.save()) return Result<_, String>, causing
fragile string-based error handling; define a TaskStoreError enum (variants like
NotFound(id: String), InvalidColumn(column: String), Persistence(source:
anyhow::Error) or similar) and change signatures of update, move_task, delete
(and save) to return Result<(), TaskStoreError>; update error construction sites
that now call ok_or_else(|| format!("Task not found: {id}")) to return
Err(TaskStoreError::NotFound(id.to_string())), change the COLUMNS check in
move_task to return Err(TaskStoreError::InvalidColumn(column.to_string())), and
adapt save() to map IO/serialization failures into TaskStoreError::Persistence
so route handlers can match on TaskStoreError instead of parsing messages.
In `@src/tools/task.rs`:
- Around line 107-122: The list action in async fn execute is returning the full
serde_json::to_string_pretty(&tasks) which can exceed the 50KB tool-result
limit; after calling self.store.list and serializing with
serde_json::to_string_pretty, sanitize the resulting JSON string by stripping
base64 data URIs and long hex/blob substrings, then if the string length >
50*1024 bytes truncate it (e.g., keep the first 50KB minus room for a suffix)
and append a clear "(truncated)" notice; update the code paths in execute that
return the list output (the "list" match arm) to perform this
sanitize-and-truncate step before returning.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (3)
Cargo.lockis excluded by!**/*.lockpanel/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlpanel/src/assets/react.svgis excluded by!**/*.svg
📒 Files selected for processing (61)
.gitignoreCLAUDE.mdCargo.tomlpanel/.gitignorepanel/README.mdpanel/eslint.config.jspanel/index.htmlpanel/package.jsonpanel/src/App.csspanel/src/App.tsxpanel/src/components/AgentDesk.tsxpanel/src/components/ChatBubble.tsxpanel/src/components/KanbanCard.tsxpanel/src/components/KanbanColumn.tsxpanel/src/components/Layout.tsxpanel/src/components/Sidebar.tsxpanel/src/components/ToolCallBlock.tsxpanel/src/hooks/useAuth.tspanel/src/hooks/useHealth.tspanel/src/hooks/useMetrics.tspanel/src/hooks/useWebSocket.tspanel/src/index.csspanel/src/lib/api.tspanel/src/main.tsxpanel/src/pages/Agents.tsxpanel/src/pages/CronRoutines.tsxpanel/src/pages/Dashboard.tsxpanel/src/pages/Kanban.tsxpanel/src/pages/Login.tsxpanel/src/pages/Logs.tsxpanel/src/pages/Sessions.tsxpanel/tsconfig.app.jsonpanel/tsconfig.jsonpanel/tsconfig.node.jsonpanel/vite.config.tssrc/agent/loop.rssrc/api/auth.rssrc/api/config.rssrc/api/events.rssrc/api/middleware.rssrc/api/mod.rssrc/api/routes/auth.rssrc/api/routes/channels.rssrc/api/routes/cron.rssrc/api/routes/health.rssrc/api/routes/metrics.rssrc/api/routes/mod.rssrc/api/routes/routines.rssrc/api/routes/sessions.rssrc/api/routes/tasks.rssrc/api/routes/ws.rssrc/api/server.rssrc/api/tasks.rssrc/cli/mod.rssrc/cli/panel.rssrc/config/mod.rssrc/config/types.rssrc/config/validate.rssrc/lib.rssrc/tools/mod.rssrc/tools/task.rs
| #root { | ||
| max-width: 1280px; | ||
| margin: 0 auto; | ||
| padding: 2rem; | ||
| text-align: center; | ||
| } | ||
|
|
||
| .logo { | ||
| height: 6em; | ||
| padding: 1.5em; | ||
| will-change: filter; | ||
| transition: filter 300ms; | ||
| } | ||
| .logo:hover { | ||
| filter: drop-shadow(0 0 2em #646cffaa); | ||
| } | ||
| .logo.react:hover { | ||
| filter: drop-shadow(0 0 2em #61dafbaa); | ||
| } | ||
|
|
||
| @keyframes logo-spin { | ||
| from { | ||
| transform: rotate(0deg); | ||
| } | ||
| to { | ||
| transform: rotate(360deg); | ||
| } | ||
| } | ||
|
|
||
| @media (prefers-reduced-motion: no-preference) { | ||
| a:nth-of-type(2) .logo { | ||
| animation: logo-spin infinite 20s linear; | ||
| } | ||
| } | ||
|
|
||
| .card { | ||
| padding: 2em; | ||
| } | ||
|
|
||
| .read-the-docs { | ||
| color: #888; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify whether App.css is imported and whether template selectors are actually used.
echo "== Imports of App.css =="
rg -n --type=ts --type=tsx 'App\.css' panel/src/
echo
echo "== Usage of template selectors/classes in panel/src =="
rg -n --type=ts --type=tsx 'className=.*(logo|card|read-the-docs)'
echo
echo "== Direct class name usage (logo, card, read-the-docs) =="
rg -n --type=ts --type=tsx '(logo|card|read-the-docs)' panel/src/ | grep -v node_modules
echo
echo "== Root element targeting in CSS =="
rg -n --type=css '#root' panel/src/
echo
echo "== List all CSS files in panel/src =="
fd -e css panel/src/Repository: qhkm/zeptoclaw
Length of output: 748
🏁 Script executed:
#!/bin/bash
# Verify App.css usage with corrected syntax
echo "== Imports of App.css =="
rg 'App\.css' panel/src/ --glob '*.ts' --glob '*.tsx'
echo
echo "== Check if any TSX/TS files exist =="
fd --full-path panel/src --extension ts --extension tsx
echo
echo "== All CSS files =="
fd --full-path panel/src --extension css
echo
echo "== Classes in panel/src =="
rg 'className.*(' panel/src/ --glob '*.tsx' -A1 | head -30
echo
echo "== Direct match for class names =="
rg 'logo|card|read-the-docs' panel/src/ --glob '*.tsx' --glob '*.ts'
echo
echo "== App.tsx content (main entry) =="
cat panel/src/App.tsx 2>/dev/null || echo "App.tsx not found"Repository: qhkm/zeptoclaw
Length of output: 3713
🏁 Script executed:
#!/bin/bash
echo "== Check imports of index.css =="
rg 'index\.css' panel/src/ --glob '*.ts' --glob '*.tsx'
echo
echo "== Check main.tsx (likely entry point) =="
cat panel/src/main.tsx
echo
echo "== Content of index.css =="
cat panel/src/index.cssRepository: qhkm/zeptoclaw
Length of output: 916
Remove unused App.css file.
App.css is not imported anywhere and contains only Vite starter template styles that are never used. The panel uses Tailwind CSS instead (imported via index.css). This file can be safely deleted.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@panel/src/App.css` around lines 1 - 42, Delete the unused App.css file (the
Vite starter styles shown) from the repo and ensure there are no remaining
imports of "App.css" anywhere in the codebase (search for the filename in files
like App.jsx / main.jsx); if any import exists, remove it so Tailwind via
index.css remains the single source of styles and no build references to App.css
remain.
| const [authVersion, setAuthVersion] = useState(0) | ||
| const { isAuthenticated } = useAuth() | ||
|
|
||
| // If no token is stored, prompt for a password. | ||
| // The panel may also run without password auth (static API token pre-set | ||
| // in localStorage), in which case isAuthenticated is already true. | ||
| if (!isAuthenticated) { | ||
| return ( | ||
| <Login | ||
| onSuccess={() => { | ||
| // Bump version so App re-reads localStorage and shows the dashboard. | ||
| setAuthVersion((v) => v + 1) | ||
| }} | ||
| /> | ||
| ) | ||
| } | ||
|
|
||
| // authVersion is consumed only to force a re-render after login. | ||
| void authVersion |
There was a problem hiding this comment.
Authentication gate can stay locked after successful login.
Line [16] reads auth state from App’s useAuth instance, while Line [23] renders Login, which uses a different useAuth instance. Bumping authVersion on Line [26] only re-renders; it does not refresh App’s token state. This can leave users stuck on the login screen even after a successful sign-in.
Use a single auth state source (e.g., auth context/provider, or hoist login/token state to App and pass handlers into Login).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@panel/src/App.tsx` around lines 15 - 33, The App component currently reads
isAuthenticated from its useAuth while Login uses a different auth instance, so
bumping authVersion only re-renders and may leave the gate locked; fix by
centralizing auth state: either hoist the login/token state into App and pass
the login handler and token setter into <Login /> (replace the onSuccess
callback with a call that updates the shared token/login state via setAuth or
setAuthVersion and token setter), or ensure both App and Login use the same auth
context/provider instance (use the same useAuth provider wrapping App and Login)
so that calling the Login login method updates isAuthenticated observed by App;
update references to useAuth, Login, authVersion and setAuthVersion accordingly
so the sign-in action mutates the single shared auth source.
| const connect = useCallback(() => { | ||
| if (!mounted.current) return | ||
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' | ||
| const ws = new WebSocket(`${protocol}//${window.location.host}/ws/events`) | ||
| wsRef.current = ws |
There was a problem hiding this comment.
WebSocket handshake currently skips auth token propagation.
On Line 23, the connection is opened without any auth material, and this hook never reads auth state from useAuth. In authenticated mode, /ws/events can fail and break live pages.
🔧 Proposed fix
import { useEffect, useRef, useState, useCallback } from 'react'
+import { useAuth } from './useAuth'
@@
export function useWebSocket(maxEvents = 50) {
+ const { token } = useAuth() // adapt property name to actual useAuth API
const [events, setEvents] = useState<PanelEvent[]>([])
@@
const connect = useCallback(() => {
@@
- const ws = new WebSocket(`${protocol}//${window.location.host}/ws/events`)
+ const qp = token ? `?token=${encodeURIComponent(token)}` : ''
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws/events${qp}`)
@@
- }, [maxEvents])
+ }, [maxEvents, token])As per coding guidelines: panel/src/**/*.ts must “Support Bearer token auth via useAuth hook.”
Also applies to: 53-61
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@panel/src/hooks/useWebSocket.ts` around lines 20 - 24, The WebSocket
connection in the connect function of the useWebSocket hook is opened without
auth and never reads auth from useAuth; update the hook to import/use useAuth,
retrieve the current bearer token (e.g., const { token } = useAuth()), and
propagate it when creating the WebSocket (for browser WS you can include the
token in the connection URL as a query param or via Sec-WebSocket-Protocol,
e.g., new
WebSocket(`${protocol}//${window.location.host}/ws/events?auth=${encodeURIComponent(token)}`)
or new WebSocket(url, `Bearer ${token}`)); apply the same change to the
reconnection logic (the later WebSocket creation around lines 53-61) and ensure
wsRef.current is still assigned and connection fails gracefully if token is
missing.
| if !dev && !api_only && static_dir.is_some() { | ||
| println!( | ||
| "Panel UI: http://{}:{}", | ||
| panel_config.bind, panel_config.port | ||
| ); |
There was a problem hiding this comment.
Displayed Panel UI URL may point to the wrong port.
At Line 192-194 you print panel_config.port, but this command only starts start_server(...) on panel_config.api_port (Line 199). When ports differ, the printed UI URL can be dead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/cli/panel.rs` around lines 190 - 194, The printed Panel UI URL uses
panel_config.port but the server is actually started with panel_config.api_port
(see start_server(...)), so update the println to reference
panel_config.api_port (or print both ports clearly) so the displayed URL matches
the port passed to start_server; locate the print block that references
panel_config.port and replace it with panel_config.api_port (or format a message
showing panel_config.port and panel_config.api_port) to ensure the URL is
correct when ports differ.
| PanelAuthAction::Mode { mode } => { | ||
| // TODO: persist auth mode to panel config | ||
| println!("Auth mode set to: {mode}"); | ||
| Ok(()) | ||
| } | ||
| PanelAuthAction::ResetPassword => { | ||
| // TODO: prompt for new password, hash with bcrypt, persist | ||
| println!("Password reset is not yet implemented."); | ||
| Ok(()) |
There was a problem hiding this comment.
Auth subcommands return success without applying changes.
Mode and ResetPassword paths are TODOs (Line 368, Line 373) but still return Ok(()), which makes CLI callers think the operation succeeded.
I can draft the persistence + password-hash implementation and help split it into a focused follow-up patch if you want.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/cli/panel.rs` around lines 367 - 375, The PanelAuthAction handlers
currently print TODO messages but still return Ok(()) which incorrectly signals
success; update the match arms for PanelAuthAction::Mode { mode } and
PanelAuthAction::ResetPassword in the function handling these actions so that
Mode persists the selected mode to the panel config (e.g., update the config
struct/store and save) and returns Ok(()) only on successful persistence, and
implement ResetPassword to prompt for the new password, bcrypt-hash it, store
the hash in the panel config, and return Ok(()) only on successful hashing and
persistence; if any step fails, return an appropriate Err detailing the failure
instead of Ok(()).
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/agent/loop.rs`:
- Around line 883-889: The PanelEvent::ToolFailed payload is inconsistent:
non-streaming uses e.to_string() while the streaming path sends result.clone()
(with "Error: ..." prefix), causing event-contract drift; update the streaming
failure branch to produce the same normalized error string as the non-streaming
path (use the same error extraction/formatting logic) and factor the emit logic
into a single helper that takes tool name and &str error to call
event_bus.send(PanelEvent::ToolFailed { tool: name.clone(), error:
normalized_error.to_string() }); ensure both the non-streaming (where e is
available) and streaming (where result or partial messages are available) paths
call that helper so future changes stay consistent.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
Cargo.tomlsrc/agent/loop.rssrc/api/config.rssrc/api/mod.rssrc/cli/mod.rssrc/config/mod.rssrc/config/types.rssrc/lib.rssrc/tools/mod.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- src/config/mod.rs
- src/cli/mod.rs
axum, tower-http, jsonwebtoken, and bcrypt are now optional. Default builds exclude all panel code; run with --features panel to include the control panel API server and CLI commands. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- api.ts: auto-inject X-CSRF-Token on mutations + retry on 403 - useWebSocket: pass auth token as ?auth= query param in WS URL - App.tsx: fix auth state sync by passing login from parent useAuth to Login, removing the authVersion re-render hack - Login.tsx: accept login/error/loading as props from parent - AgentDesk: guard formatRelativeTime against NaN timestamps - KanbanCard: use stable keys for label pills (index+label) - App.css: remove unused Vite boilerplate file - Logs: prioritize error classification before tool_ prefix check - Logs: fix type-only import for PanelEvent (verbatimModuleSyntax) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CSRF: replace weak polynomial hash with HMAC-SHA256 (ring::hmac) + constant-time compare + reject future timestamps (>60s ahead) - WebSocket: require auth token via ?auth= query param before upgrade (browsers cannot set headers during WS handshake) - CORS: derive origin from PanelConfig (bind:port) instead of hardcoded localhost:9092 - tasks routes: return 503 SERVICE_UNAVAILABLE when task_store is None instead of false success (200/201/204) - channels: remove redundant serde_json::to_value(json!()) wrapper - tasks store: use tokio::fs::metadata for async path check; atomic writes via tmp+rename to prevent partial/corrupt reads - cli/panel: fix printed URL (use api_port for API, port for frontend); --download returns error instead of silently succeeding; auth mode and reset-password return error instead of Ok(()); wire TaskStore into AppState on cmd_start - config: PANEL_ENABLED env var accepts case-insensitive "true" and "1" - tools/task: truncate list output at 50KB to stay within LLM context Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1e87bab to
ca28957
Compare
There was a problem hiding this comment.
Actionable comments posted: 17
♻️ Duplicate comments (2)
src/agent/loop.rs (1)
1399-1410:⚠️ Potential issue | 🟡 Minor
ToolFailed.errorpayload is still inconsistent in streaming path.Line 1409 emits
result.clone()(typically"Error: ..."), while the non-streaming path emits rawe.to_string(). This still causes event-contract drift for panel consumers.🔧 Proposed fix
- match tools_guard.execute_with_context(&name, args, &ctx).await { + match tools_guard.execute_with_context(&name, args, &ctx).await { Ok(output) => { - (output.for_llm, !output.is_error) + (output.for_llm, !output.is_error, None) } - Err(e) => (format!("Error: {}", e), false), + Err(e) => { + let err = e.to_string(); + (format!("Error: {}", err), false, Some(err)) + } } }; @@ - bus.send(crate::api::events::PanelEvent::ToolFailed { + bus.send(crate::api::events::PanelEvent::ToolFailed { tool: name.clone(), - error: result.clone(), + error: event_error.unwrap_or_else(|| result.clone()), });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/agent/loop.rs` around lines 1399 - 1410, The streaming path emits PanelEvent::ToolFailed with error: result.clone(), causing inconsistency with the non-streaming path which uses e.to_string(); change the streaming branch that constructs PanelEvent::ToolFailed to convert the error into the same string form (e.g., use result.to_string() or the same e.to_string() logic used elsewhere) so the PanelEvent::ToolFailed.error payload matches the non-streaming path; update the block where event_bus sends PanelEvent::ToolFailed (variables: event_bus, success, result, name, latency_ms) to use the normalized string conversion.src/api/routes/cron.rs (1)
11-42:⚠️ Potential issue | 🔴 CriticalCron handlers are still non-functional stubs returning fake success.
All CRUD/trigger routes ignore
AppStateand request inputs, but return success-like responses. This breaks API correctness and will mislead the UI/automation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/routes/cron.rs` around lines 11 - 42, All cron route handlers (list_jobs, create_job, update_job, delete_job, trigger_job) are stubs that ignore the provided AppState, Path and Json inputs; replace the stub logic with real interactions against the application state: use the passed State<Arc<AppState>> to call the scheduler/job-store methods (e.g. AppState::list_jobs, ::create_job, ::update_job, ::delete_job, ::trigger_job or equivalent), parse and validate the Json body in create_job/update_job, use the Path(id) value in update/delete/trigger, map domain errors to appropriate StatusCode responses, and return real Json payloads (job lists, created id/object, etc.) instead of hardcoded values so the API reflects actual success/failure from the backend.
🧹 Nitpick comments (2)
panel/src/components/Sidebar.tsx (1)
9-16: Consider using an icon library instead of Unicode characters.Unicode symbols (◈, ≡, ⏱, etc.) can render inconsistently across platforms, fonts, and browsers. For a dashboard UI, consider using a React icon library like
lucide-reactor@heroicons/reactfor reliable, scalable SVG icons with consistent appearance.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@panel/src/components/Sidebar.tsx` around lines 9 - 16, Replace the inline Unicode glyphs in the navItems array with SVG icon components from an icon library (e.g., lucide-react or `@heroicons/react`): add the necessary imports at the top of Sidebar.tsx (e.g., DashboardIcon, ClockIcon, etc.), update the NavItem.icon type to accept a ReactNode if needed, and change each navItems entry (referenced as navItems and the NavItem type) to use the imported icon component instead of the Unicode string; ensure Sidebar's render logic uses the icon ReactNode directly when rendering the list.panel/src/hooks/useAuth.ts (1)
40-43: Clear CSRF cache during logout.Line 41 removes the JWT, but cached CSRF state can survive across logouts and cause avoidable stale-token retries on the next mutating call.
Suggested patch
-import { apiFetch } from '../lib/api' +import { apiFetch, clearCsrfToken } from '../lib/api' @@ const logout = useCallback(() => { localStorage.removeItem('panel_token') + clearCsrfToken() setToken(null) }, [])🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@panel/src/hooks/useAuth.ts` around lines 40 - 43, The logout function in useAuth.ts currently removes only the JWT (localStorage.removeItem('panel_token') and setToken(null)), but it must also clear any cached CSRF state to avoid stale-token retries; update the logout callback to remove the CSRF cache (e.g., localStorage.removeItem('panel_csrf') or the actual CSRF key used), reset any in-memory CSRF variable (e.g., csrfRef or csrfStore) and clear the HTTP client's default CSRF header (e.g., delete axios.defaults.headers.common['X-CSRF-Token'] or call the existing clearCsrfCache() helper if one exists) so all CSRF state is fully cleared on logout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Cargo.toml`:
- Around line 88-90: The ring crate is currently always enabled but only used by
the API compiled under the panel feature; make ring an optional dependency and
add it to the panel feature so it is only downloaded/linked when panel is
enabled: change the ring dependency in Cargo.toml to optional = true (and remove
any version duplication), then add "ring" to the panel feature list in the
[features] section so the panel feature activates the ring dependency; update
the dependency entry and the panel feature definition accordingly.
In `@CLAUDE.md`:
- Around line 119-125: Update the documentation so it clearly marks the panel
commands as feature-gated by the `panel` feature; change the quick-reference
entries that list `zeptoclaw panel`, `zeptoclaw panel install`, `zeptoclaw panel
auth set-password`, `zeptoclaw panel auth show-token`, and `zeptoclaw panel
uninstall` to indicate they require building with `--features panel` (e.g., add
“(requires --features panel)” or similar), and apply the same change to the
other referenced doc sections that mention `zeptoclaw panel` to keep
README/docs/CLAUDE.md claims aligned with executable behavior.
In `@panel/README.md`:
- Around line 1-73: The README currently contains generic React/Vite template
content; replace it with panel-specific operational documentation that explains
how to run the control panel, authentication steps, API endpoints and examples,
and WebSocket usage for real-time updates. Update or replace existing sections
such as "React Compiler" and "Expanding the ESLint configuration" with concise
subsections: "Getting Started" (dev and build commands), "Authentication" (login
flow and tokens), "API" (key endpoints, request/response examples and required
headers), and "WebSocket" (connection URL, message types and example payloads);
ensure commands, environment variables, and minimal troubleshooting tips are
included so someone can use the panel without reading the code.
In `@panel/src/lib/api.ts`:
- Around line 11-16: The getCsrfToken function currently parses the response
body unconditionally which can throw an opaque error if the /api/csrf-token
endpoint returns a non-2xx status or non-JSON body; update getCsrfToken to first
check the fetch Response (res.ok) and, if not ok, read the response text and
throw a descriptive error containing the status and response text, and wrap the
res.json() call in try/catch to surface a clear error if JSON parsing fails;
ensure you still set and return the module-level csrfToken when a valid token is
obtained.
- Around line 28-32: The headers build currently casts init.headers to a plain
object which drops values when init.headers is a Headers instance or header
tuples; change the normalization to construct a Headers object and then convert
it to a plain record before spreading. Specifically, in api.ts where the const
headers: Record<string,string> is built (the headers variable creation), replace
the direct cast of init.headers with a conversion like creating new
Headers(init.headers ?? undefined) and then using
Object.fromEntries(headersInstance.entries()) (or equivalent) so tuple/Headers
inputs are preserved and merged with Content-Type and Authorization correctly.
In `@panel/src/pages/Agents.tsx`:
- Around line 138-145: Replace the persistent Set used to track processed events
with a WeakSet to avoid unbounded memory growth: change the processedRef
initialization from useRef(new Set<object>()) to useRef<WeakSet<object>>(new
WeakSet<object>()), and keep the same checks inside the useEffect
(processedRef.current.has(event) and processedRef.current.add(event)) since
WeakSet supports has/add for object-valued events; ensure events are objects
(not primitives) before adding.
In `@panel/src/pages/CronRoutines.tsx`:
- Around line 413-416: The useQuery call currently types and treats the response
as CronJob[] but the API returns { jobs: CronJob[] }; update the query to use
useQuery<{jobs: CronJob[]}> with queryFn: () => apiFetch<{jobs:
CronJob[]}>('/api/cron') and then derive the array (e.g. const jobs = data?.jobs
?? []) so all downstream uses (jobs.length, table rendering) operate on the
actual array returned by the cron route; adjust any references to the previous
data variable if needed.
In `@panel/src/pages/Kanban.tsx`:
- Around line 11-12: The code uses the React namespace type React.FormEvent
(e.g. at the submit handler) but React is not imported, causing a TS error; fix
by importing FormEvent as a named import from React (import { useState, useMemo,
FormEvent } from 'react') and then change any React.FormEvent annotations to
just FormEvent (or alternatively import the default React and keep
React.FormEvent), updating occurrences of React.FormEvent in this file
accordingly.
In `@panel/src/pages/Logs.tsx`:
- Around line 122-126: The current clear implementation only empties local
displayedEvents (in the useEffect that syncs liveEvents → displayedEvents and in
handleClear), so on the next live sync the previous history from liveEvents is
restored; update the logic to persist a clear cursor or clear state in the
source of truth (e.g., add a clearEvents() API or resetCursor(timestamp/eventId)
in useWebSocket) and have useEffect respect that marker when merging liveEvents
into displayedEvents (reference displayedEvents, liveEvents, the useEffect
syncing them, and handleClear to locate where to trigger the new API/marker);
ensure clearEvents() causes liveEvents to be treated as empty until new events
arrive or a resume clears the marker.
In `@src/api/events.rs`:
- Around line 38-40: The constructor Events::new currently calls
broadcast::channel(capacity) which panics if capacity == 0; add a defensive
clamp so capacity_used = if capacity == 0 { 1 } else { capacity } (or
capacity.max(1)) before calling broadcast::channel in the new function to ensure
a nonzero buffer and avoid runtime panic when creating Self { tx }.
In `@src/api/routes/auth.rs`:
- Around line 42-62: Add an IP+username keyed rate limiter around the login flow
in the login function: before calling crate::api::auth::verify_password,
check/increment a shared rate-limit store on AppState (e.g., a Mutex/HashMap or
a governor/tower based limiter referenced from AppState) keyed by (request IP,
body.username); if the key is over threshold return
StatusCode::TOO_MANY_REQUESTS or apply exponential backoff delay, otherwise
proceed; on successful authentication reset/clear the key; ensure lookups use
the incoming request IP (extract from axum's ConnectInfo or headers) and that
the limiter entries expire after a configurable TTL to avoid memory growth.
In `@src/api/routes/sessions.rs`:
- Around line 22-24: The routes currently return empty success-shaped JSON when
state.session_manager is None; instead detect when state.session_manager is
missing (the let Some(ref manager) = state.session_manager checks) and return an
HTTP 503 Service Unavailable with an error body (similar format to
src/api/routes/tasks.rs) rather than Json({ "sessions": [] }) or other empty
success responses; update all handlers that use state.session_manager (the
session lookup/list/delete branches) to return a 503 response with a clear error
message when session_manager is None.
In `@src/api/routes/tasks.rs`:
- Around line 49-55: The current handlers (e.g., the create handler calling
store.create, and similar uses at the other locations) blanket all Err results
into 404/422; instead define and return a typed TaskStore error enum (e.g.,
TaskStoreError with variants like NotFound, Validation(String), Conflict,
Persistence(Box<dyn Error>)) from TaskStore methods (create, get, update,
delete) and update each handler to match on that enum: map NotFound ->
StatusCode::NOT_FOUND, Validation -> StatusCode::UNPROCESSABLE_ENTITY (including
the validation message), and map Persistence/other internal errors -> 5xx (e.g.,
StatusCode::INTERNAL_SERVER_ERROR) with a generic error body; ensure store
method signatures return Result<_, TaskStoreError> and adjust the match arms in
the handlers to pattern-match the enum instead of treating all Err the same.
In `@src/cli/panel.rs`:
- Around line 58-60: The status branch in the PanelAction::Auth handling (inside
cmd_auth(auth_action)) is hardcoded to "token" and references a non-existent
panel.password_hash field; update the status code to read the real runtime value
config.panel.auth_mode (use the PanelConfig enum/value) to decide whether to
print "token" or "password" and change the edit-suggestion to reference the
actual field name on your PanelConfig (replace the incorrect panel.password_hash
with the real field on the config struct). Locate the auth status logic inside
cmd_auth and replace the hardcoded strings and wrong field reference with reads
from config.panel and the correct PanelConfig field name.
In `@src/config/mod.rs`:
- Around line 260-276: Add an env override for panel.auth_mode by checking
std::env::var("ZEPTOCLAW_PANEL_AUTH_MODE") and, if present, setting
self.panel.auth_mode accordingly; if PanelConfig::auth_mode is a string field
assign the raw val, otherwise attempt to parse with val.parse() and assign on
Ok(v) (handle parse errors by ignoring the env or logging as appropriate). Use
the same pattern as the surrounding overrides (see self.panel.enabled,
self.panel.port, self.panel.api_port, self.panel.bind) and refer to the field as
self.panel.auth_mode and the env var name ZEPTOCLAW_PANEL_AUTH_MODE.
In `@src/tools/task.rs`:
- Around line 120-131: The JSON returned from
serde_json::to_string_pretty(&tasks) is only truncated to 50KB but not
sanitized; before calling ToolOutput::llm_only(...) replace/strip base64 data
URIs and long hex blobs in the tasks payload (e.g., detect patterns like
data:...;base64, and long hex sequences) with a short placeholder such as
"<stripped_blob>", then serialize
(serde_json::to_string_pretty(&tasks_sanitized)) and truncate to 50KB if needed;
apply the same sanitization logic wherever a similar path uses
serde_json::to_string_pretty and ToolOutput::llm_only (the other occurrence
noted around the second block).
- Around line 121-127: The current truncation uses &json[..50_000] which can
panic across UTF‑8 codepoint boundaries and truncates before sanitizing
sensitive blobs; to fix, produce a sanitized copy of the JSON string (scan the
string and replace base64 URI and long hex-blob patterns with masked
placeholders or shortened safe forms) and then take a UTF‑8-safe prefix from
that sanitized string (either use s.char_indices()/s.chars().take(n).collect()
or check s.is_char_boundary(index) and backtrack to the previous boundary) and
use that safe, sanitized slice in the formatted message (refer to the json
variable and the tasks.len() usage in this block).
---
Duplicate comments:
In `@src/agent/loop.rs`:
- Around line 1399-1410: The streaming path emits PanelEvent::ToolFailed with
error: result.clone(), causing inconsistency with the non-streaming path which
uses e.to_string(); change the streaming branch that constructs
PanelEvent::ToolFailed to convert the error into the same string form (e.g., use
result.to_string() or the same e.to_string() logic used elsewhere) so the
PanelEvent::ToolFailed.error payload matches the non-streaming path; update the
block where event_bus sends PanelEvent::ToolFailed (variables: event_bus,
success, result, name, latency_ms) to use the normalized string conversion.
In `@src/api/routes/cron.rs`:
- Around line 11-42: All cron route handlers (list_jobs, create_job, update_job,
delete_job, trigger_job) are stubs that ignore the provided AppState, Path and
Json inputs; replace the stub logic with real interactions against the
application state: use the passed State<Arc<AppState>> to call the
scheduler/job-store methods (e.g. AppState::list_jobs, ::create_job,
::update_job, ::delete_job, ::trigger_job or equivalent), parse and validate the
Json body in create_job/update_job, use the Path(id) value in
update/delete/trigger, map domain errors to appropriate StatusCode responses,
and return real Json payloads (job lists, created id/object, etc.) instead of
hardcoded values so the API reflects actual success/failure from the backend.
---
Nitpick comments:
In `@panel/src/components/Sidebar.tsx`:
- Around line 9-16: Replace the inline Unicode glyphs in the navItems array with
SVG icon components from an icon library (e.g., lucide-react or
`@heroicons/react`): add the necessary imports at the top of Sidebar.tsx (e.g.,
DashboardIcon, ClockIcon, etc.), update the NavItem.icon type to accept a
ReactNode if needed, and change each navItems entry (referenced as navItems and
the NavItem type) to use the imported icon component instead of the Unicode
string; ensure Sidebar's render logic uses the icon ReactNode directly when
rendering the list.
In `@panel/src/hooks/useAuth.ts`:
- Around line 40-43: The logout function in useAuth.ts currently removes only
the JWT (localStorage.removeItem('panel_token') and setToken(null)), but it must
also clear any cached CSRF state to avoid stale-token retries; update the logout
callback to remove the CSRF cache (e.g., localStorage.removeItem('panel_csrf')
or the actual CSRF key used), reset any in-memory CSRF variable (e.g., csrfRef
or csrfStore) and clear the HTTP client's default CSRF header (e.g., delete
axios.defaults.headers.common['X-CSRF-Token'] or call the existing
clearCsrfCache() helper if one exists) so all CSRF state is fully cleared on
logout.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (3)
Cargo.lockis excluded by!**/*.lockpanel/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlpanel/src/assets/react.svgis excluded by!**/*.svg
📒 Files selected for processing (60)
.gitignoreCLAUDE.mdCargo.tomlpanel/.gitignorepanel/README.mdpanel/eslint.config.jspanel/index.htmlpanel/package.jsonpanel/src/App.tsxpanel/src/components/AgentDesk.tsxpanel/src/components/ChatBubble.tsxpanel/src/components/KanbanCard.tsxpanel/src/components/KanbanColumn.tsxpanel/src/components/Layout.tsxpanel/src/components/Sidebar.tsxpanel/src/components/ToolCallBlock.tsxpanel/src/hooks/useAuth.tspanel/src/hooks/useHealth.tspanel/src/hooks/useMetrics.tspanel/src/hooks/useWebSocket.tspanel/src/index.csspanel/src/lib/api.tspanel/src/main.tsxpanel/src/pages/Agents.tsxpanel/src/pages/CronRoutines.tsxpanel/src/pages/Dashboard.tsxpanel/src/pages/Kanban.tsxpanel/src/pages/Login.tsxpanel/src/pages/Logs.tsxpanel/src/pages/Sessions.tsxpanel/tsconfig.app.jsonpanel/tsconfig.jsonpanel/tsconfig.node.jsonpanel/vite.config.tssrc/agent/loop.rssrc/api/auth.rssrc/api/config.rssrc/api/events.rssrc/api/middleware.rssrc/api/mod.rssrc/api/routes/auth.rssrc/api/routes/channels.rssrc/api/routes/cron.rssrc/api/routes/health.rssrc/api/routes/metrics.rssrc/api/routes/mod.rssrc/api/routes/routines.rssrc/api/routes/sessions.rssrc/api/routes/tasks.rssrc/api/routes/ws.rssrc/api/server.rssrc/api/tasks.rssrc/cli/mod.rssrc/cli/panel.rssrc/config/mod.rssrc/config/types.rssrc/config/validate.rssrc/lib.rssrc/tools/mod.rssrc/tools/task.rs
✅ Files skipped from review due to trivial changes (2)
- src/api/routes/routines.rs
- panel/.gitignore
🚧 Files skipped from review as they are similar to previous changes (21)
- panel/tsconfig.app.json
- panel/src/pages/Login.tsx
- src/tools/mod.rs
- panel/src/components/KanbanColumn.tsx
- src/config/validate.rs
- panel/src/components/KanbanCard.tsx
- panel/src/pages/Dashboard.tsx
- panel/src/index.css
- src/api/auth.rs
- panel/src/pages/Sessions.tsx
- src/api/routes/channels.rs
- .gitignore
- panel/package.json
- panel/src/hooks/useWebSocket.ts
- src/api/routes/ws.rs
- panel/src/App.tsx
- panel/src/components/ChatBubble.tsx
- panel/src/components/Layout.tsx
- src/lib.rs
- panel/src/main.tsx
- panel/vite.config.ts
| # HMAC-SHA256 for CSRF token generation/validation | ||
| ring = "0.17" | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check ring imports and usage in the codebase
echo "=== Ring imports and usage ==="
rg -n --type=rust '\bring::|use\s+ring\b' src/ || echo "No ring usage found"
echo -e "\n=== Panel feature guards ==="
rg -n --type=rust 'cfg\(feature\s*=\s*"panel"\)' src/ || echo "No panel feature guards found"
echo -e "\n=== Cargo.toml: ring line and surrounding context ==="
cat -n Cargo.toml | sed -n '85,95p'
echo -e "\n=== Cargo.toml: panel feature definition ==="
cat -n Cargo.toml | sed -n '240,250p'
echo -e "\n=== Cargo.toml: optional deps section ==="
cat -n Cargo.toml | sed -n '190,210p'Repository: qhkm/zeptoclaw
Length of output: 4176
🏁 Script executed:
#!/bin/bash
# Check if api/middleware module is feature-gated
echo "=== Check api module declaration ==="
rg -n 'mod\s+api|api::' src/lib.rs src/main.rs src/api/mod.rs 2>/dev/null | head -20
echo -e "\n=== Check if middleware.rs has feature guards ==="
head -20 src/api/middleware.rs | cat -n
echo -e "\n=== Check api/mod.rs for panel guards ==="
cat -n src/api/mod.rs | head -30
echo -e "\n=== Verify ring is not in optional deps section ==="
grep -n 'ring' Cargo.tomlRepository: qhkm/zeptoclaw
Length of output: 1502
🏁 Script executed:
#!/bin/bash
# Check context around api module declaration in lib.rs
echo "=== Context around api module in lib.rs (lines 1-15) ==="
head -15 src/lib.rs | cat -n
echo -e "\n=== Also check if there's #[cfg feature guard before line 5 ==="
sed -n '1,10p' src/lib.rs | cat -nRepository: qhkm/zeptoclaw
Length of output: 799
ring should be feature-gated under panel alongside other API dependencies.
Currently, ring is always-on (line 89) while the api module that uses it is conditionally compiled only when the panel feature is enabled. This means builds without the panel feature still download and link the ring crate, defeating lean-build objectives. Move ring to optional and add it to the panel feature definition.
🔧 Proposed fix
- ring = "0.17"
+ ring = { version = "0.17", optional = true }
- panel = ["dep:axum", "dep:tower-http", "dep:jsonwebtoken", "dep:bcrypt"]
+ panel = ["dep:axum", "dep:tower-http", "dep:jsonwebtoken", "dep:bcrypt", "dep:ring"]🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Cargo.toml` around lines 88 - 90, The ring crate is currently always enabled
but only used by the API compiled under the panel feature; make ring an optional
dependency and add it to the panel feature so it is only downloaded/linked when
panel is enabled: change the ring dependency in Cargo.toml to optional = true
(and remove any version duplication), then add "ring" to the panel feature list
in the [features] section so the panel feature activates the ring dependency;
update the dependency entry and the panel feature definition accordingly.
| # Panel (control panel web UI) | ||
| ./target/release/zeptoclaw panel # Start panel API server | ||
| ./target/release/zeptoclaw panel install # Install panel frontend (build from source) | ||
| ./target/release/zeptoclaw panel auth set-password # Set panel password | ||
| ./target/release/zeptoclaw panel auth show-token # Show API token | ||
| ./target/release/zeptoclaw panel uninstall # Remove panel frontend | ||
|
|
There was a problem hiding this comment.
Document panel as feature-gated (--features panel).
The quick reference and feature section currently read as if zeptoclaw panel is always available, but the CLI command is compiled only with the panel feature. This will break copy-paste flows on default builds.
📝 Suggested doc update
-# Panel (control panel web UI)
+# Panel (control panel web UI, requires `--features panel`)
+cargo build --release --features panel
./target/release/zeptoclaw panel ### Cargo Features
+- `panel` — Enable panel API/CLI/web dashboard support (axum, tower-http, jsonwebtoken, bcrypt)
- `android` — Enable Android device control tool (adds `quick-xml` dependency)As per coding guidelines {README.md,docs/**/*.md,CLAUDE.md}: “Keep README/docs claims aligned with executable behavior”.
Also applies to: 585-595, 717-720
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CLAUDE.md` around lines 119 - 125, Update the documentation so it clearly
marks the panel commands as feature-gated by the `panel` feature; change the
quick-reference entries that list `zeptoclaw panel`, `zeptoclaw panel install`,
`zeptoclaw panel auth set-password`, `zeptoclaw panel auth show-token`, and
`zeptoclaw panel uninstall` to indicate they require building with `--features
panel` (e.g., add “(requires --features panel)” or similar), and apply the same
change to the other referenced doc sections that mention `zeptoclaw panel` to
keep README/docs/CLAUDE.md claims aligned with executable behavior.
| # React + TypeScript + Vite | ||
|
|
||
| This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. | ||
|
|
||
| Currently, two official plugins are available: | ||
|
|
||
| - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh | ||
| - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh | ||
|
|
||
| ## React Compiler | ||
|
|
||
| The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). | ||
|
|
||
| ## Expanding the ESLint configuration | ||
|
|
||
| If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: | ||
|
|
||
| ```js | ||
| export default defineConfig([ | ||
| globalIgnores(['dist']), | ||
| { | ||
| files: ['**/*.{ts,tsx}'], | ||
| extends: [ | ||
| // Other configs... | ||
|
|
||
| // Remove tseslint.configs.recommended and replace with this | ||
| tseslint.configs.recommendedTypeChecked, | ||
| // Alternatively, use this for stricter rules | ||
| tseslint.configs.strictTypeChecked, | ||
| // Optionally, add this for stylistic rules | ||
| tseslint.configs.stylisticTypeChecked, | ||
|
|
||
| // Other configs... | ||
| ], | ||
| languageOptions: { | ||
| parserOptions: { | ||
| project: ['./tsconfig.node.json', './tsconfig.app.json'], | ||
| tsconfigRootDir: import.meta.dirname, | ||
| }, | ||
| // other options... | ||
| }, | ||
| }, | ||
| ]) | ||
| ``` | ||
|
|
||
| You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: | ||
|
|
||
| ```js | ||
| // eslint.config.js | ||
| import reactX from 'eslint-plugin-react-x' | ||
| import reactDom from 'eslint-plugin-react-dom' | ||
|
|
||
| export default defineConfig([ | ||
| globalIgnores(['dist']), | ||
| { | ||
| files: ['**/*.{ts,tsx}'], | ||
| extends: [ | ||
| // Other configs... | ||
| // Enable lint rules for React | ||
| reactX.configs['recommended-typescript'], | ||
| // Enable lint rules for React DOM | ||
| reactDom.configs.recommended, | ||
| ], | ||
| languageOptions: { | ||
| parserOptions: { | ||
| project: ['./tsconfig.node.json', './tsconfig.app.json'], | ||
| tsconfigRootDir: import.meta.dirname, | ||
| }, | ||
| // other options... | ||
| }, | ||
| }, | ||
| ]) | ||
| ``` |
There was a problem hiding this comment.
README content is template-level, not control-panel operational docs.
Lines 1-73 still describe generic React/Vite scaffolding. For this PR, it should document panel-specific run/auth/API/WebSocket usage so the feature is usable without code-diving.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@panel/README.md` around lines 1 - 73, The README currently contains generic
React/Vite template content; replace it with panel-specific operational
documentation that explains how to run the control panel, authentication steps,
API endpoints and examples, and WebSocket usage for real-time updates. Update or
replace existing sections such as "React Compiler" and "Expanding the ESLint
configuration" with concise subsections: "Getting Started" (dev and build
commands), "Authentication" (login flow and tokens), "API" (key endpoints,
request/response examples and required headers), and "WebSocket" (connection
URL, message types and example payloads); ensure commands, environment
variables, and minimal troubleshooting tips are included so someone can use the
panel without reading the code.
| async function getCsrfToken(): Promise<string> { | ||
| if (csrfToken) return csrfToken | ||
| const res = await fetch('/api/csrf-token') | ||
| const data = await res.json() | ||
| csrfToken = data.token as string | ||
| return csrfToken |
There was a problem hiding this comment.
Handle CSRF endpoint failures before JSON parsing.
Line 14 parses JSON unconditionally. If /api/csrf-token returns non-2xx or non-JSON, this throws an opaque parse error and hides the real failure.
Suggested patch
async function getCsrfToken(): Promise<string> {
if (csrfToken) return csrfToken
const res = await fetch('/api/csrf-token')
- const data = await res.json()
- csrfToken = data.token as string
+ if (!res.ok) {
+ throw new Error(`CSRF ${res.status}: ${res.statusText}`)
+ }
+ const data: unknown = await res.json()
+ const token = (data as { token?: unknown }).token
+ if (typeof token !== 'string' || token.length === 0) {
+ throw new Error('CSRF response missing token')
+ }
+ csrfToken = token
return csrfToken
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@panel/src/lib/api.ts` around lines 11 - 16, The getCsrfToken function
currently parses the response body unconditionally which can throw an opaque
error if the /api/csrf-token endpoint returns a non-2xx status or non-JSON body;
update getCsrfToken to first check the fetch Response (res.ok) and, if not ok,
read the response text and throw a descriptive error containing the status and
response text, and wrap the res.json() call in try/catch to surface a clear
error if JSON parsing fails; ensure you still set and return the module-level
csrfToken when a valid token is obtained.
| const headers: Record<string, string> = { | ||
| 'Content-Type': 'application/json', | ||
| ...(token ? { Authorization: `Bearer ${token}` } : {}), | ||
| ...(init.headers as Record<string, string> ?? {}), | ||
| } |
There was a problem hiding this comment.
Normalize init.headers with Headers instead of record-casting.
Line 31 assumes init.headers is a plain object. Passing Headers or tuple headers can lose values when spread-cast.
Suggested patch
- const headers: Record<string, string> = {
- 'Content-Type': 'application/json',
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- ...(init.headers as Record<string, string> ?? {}),
- }
+ const headers = new Headers(init.headers)
+ if (!headers.has('Content-Type') && !(init.body instanceof FormData)) {
+ headers.set('Content-Type', 'application/json')
+ }
+ if (token) {
+ headers.set('Authorization', `Bearer ${token}`)
+ }
@@
- headers['X-CSRF-Token'] = await getCsrfToken()
+ headers.set('X-CSRF-Token', await getCsrfToken())
@@
- headers['X-CSRF-Token'] = await getCsrfToken()
+ headers.set('X-CSRF-Token', await getCsrfToken())🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@panel/src/lib/api.ts` around lines 28 - 32, The headers build currently casts
init.headers to a plain object which drops values when init.headers is a Headers
instance or header tuples; change the normalization to construct a Headers
object and then convert it to a plain record before spreading. Specifically, in
api.ts where the const headers: Record<string,string> is built (the headers
variable creation), replace the direct cast of init.headers with a conversion
like creating new Headers(init.headers ?? undefined) and then using
Object.fromEntries(headersInstance.entries()) (or equivalent) so tuple/Headers
inputs are preserved and merged with Content-Type and Authorization correctly.
| match store.create(title, column, assignee).await { | ||
| Ok(id) => (StatusCode::CREATED, Json(json!({ "id": id }))), | ||
| Err(e) => ( | ||
| StatusCode::UNPROCESSABLE_ENTITY, | ||
| Json(json!({ "error": e })), | ||
| ), | ||
| } |
There was a problem hiding this comment.
Error-to-status mapping is too coarse and can misreport server failures as client errors.
These handlers map broad Err cases to 404/422. If persistence/save fails, callers should get 5xx, not "not found" or "unprocessable".
Please classify errors explicitly (ideally via a typed TaskStore error enum) and reserve 404/422 for true client-facing conditions only.
Also applies to: 67-70, 78-81, 106-116
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/routes/tasks.rs` around lines 49 - 55, The current handlers (e.g.,
the create handler calling store.create, and similar uses at the other
locations) blanket all Err results into 404/422; instead define and return a
typed TaskStore error enum (e.g., TaskStoreError with variants like NotFound,
Validation(String), Conflict, Persistence(Box<dyn Error>)) from TaskStore
methods (create, get, update, delete) and update each handler to match on that
enum: map NotFound -> StatusCode::NOT_FOUND, Validation ->
StatusCode::UNPROCESSABLE_ENTITY (including the validation message), and map
Persistence/other internal errors -> 5xx (e.g.,
StatusCode::INTERNAL_SERVER_ERROR) with a generic error body; ensure store
method signatures return Result<_, TaskStoreError> and adjust the match arms in
the handlers to pattern-match the enum instead of treating all Err the same.
| Some(PanelAction::Auth { | ||
| action: auth_action, | ||
| }) => cmd_auth(auth_action).await, |
There was a problem hiding this comment.
panel auth status is not reading real config state.
Line 367 always reports token mode, regardless of actual config.panel.auth_mode, and Line 385 suggests editing panel.password_hash, which does not match the current panel config surface.
🔧 Proposed fix
- Some(PanelAction::Auth {
- action: auth_action,
- }) => cmd_auth(auth_action).await,
+ Some(PanelAction::Auth {
+ action: auth_action,
+ }) => cmd_auth(config, auth_action).await,-async fn cmd_auth(action: PanelAuthAction) -> Result<()> {
+async fn cmd_auth(config: Config, action: PanelAuthAction) -> Result<()> {
match action {
PanelAuthAction::Status => {
- println!("Auth mode: token (default)");
+ let mode = match config.panel.auth_mode {
+ zeptoclaw::config::AuthMode::Token => "token",
+ zeptoclaw::config::AuthMode::Password => "password",
+ zeptoclaw::config::AuthMode::None => "none",
+ };
+ println!("Auth mode: {mode}");
let tp = token_path();
if tp.exists() {
println!("Token file: {}", tp.display());
@@
PanelAuthAction::ResetPassword => {
anyhow::bail!(
"Password reset is not yet implemented. \
- Edit ~/.zeptoclaw/config.json manually to set panel.password_hash."
+ Use token mode for now (`zeptoclaw panel auth status`), \
+ or update panel auth handling once password mode is implemented."
);
}Also applies to: 366-386
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/cli/panel.rs` around lines 58 - 60, The status branch in the
PanelAction::Auth handling (inside cmd_auth(auth_action)) is hardcoded to
"token" and references a non-existent panel.password_hash field; update the
status code to read the real runtime value config.panel.auth_mode (use the
PanelConfig enum/value) to decide whether to print "token" or "password" and
change the edit-suggestion to reference the actual field name on your
PanelConfig (replace the incorrect panel.password_hash with the real field on
the config struct). Locate the auth status logic inside cmd_auth and replace the
hardcoded strings and wrong field reference with reads from config.panel and the
correct PanelConfig field name.
| // Panel (env overrides always applied — PanelConfig is always present in Config) | ||
| if let Ok(val) = std::env::var("ZEPTOCLAW_PANEL_ENABLED") { | ||
| self.panel.enabled = val.eq_ignore_ascii_case("true") || val == "1"; | ||
| } | ||
| if let Ok(val) = std::env::var("ZEPTOCLAW_PANEL_PORT") { | ||
| if let Ok(v) = val.parse() { | ||
| self.panel.port = v; | ||
| } | ||
| } | ||
| if let Ok(val) = std::env::var("ZEPTOCLAW_PANEL_API_PORT") { | ||
| if let Ok(v) = val.parse() { | ||
| self.panel.api_port = v; | ||
| } | ||
| } | ||
| if let Ok(val) = std::env::var("ZEPTOCLAW_PANEL_BIND") { | ||
| self.panel.bind = val; | ||
| } |
There was a problem hiding this comment.
Add missing env override for panel.auth_mode.
Line 260 says panel env overrides are applied, but there is no override for auth_mode. This leaves one PanelConfig field non-overridable at runtime.
🔧 Proposed fix
if let Ok(val) = std::env::var("ZEPTOCLAW_PANEL_BIND") {
self.panel.bind = val;
}
+ if let Ok(val) = std::env::var("ZEPTOCLAW_PANEL_AUTH_MODE") {
+ match val.trim().to_ascii_lowercase().as_str() {
+ "token" => self.panel.auth_mode = AuthMode::Token,
+ "password" => self.panel.auth_mode = AuthMode::Password,
+ "none" => self.panel.auth_mode = AuthMode::None,
+ _ => {}
+ }
+ }As per coding guidelines src/config/**/*.rs: “Support runtime environment variable overrides for all configuration options (ZEPTOCLAW_* prefix)”.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/config/mod.rs` around lines 260 - 276, Add an env override for
panel.auth_mode by checking std::env::var("ZEPTOCLAW_PANEL_AUTH_MODE") and, if
present, setting self.panel.auth_mode accordingly; if PanelConfig::auth_mode is
a string field assign the raw val, otherwise attempt to parse with val.parse()
and assign on Ok(v) (handle parse errors by ignoring the env or logging as
appropriate). Use the same pattern as the surrounding overrides (see
self.panel.enabled, self.panel.port, self.panel.api_port, self.panel.bind) and
refer to the field as self.panel.auth_mode and the env var name
ZEPTOCLAW_PANEL_AUTH_MODE.
| let json = serde_json::to_string_pretty(&tasks).unwrap_or_default(); | ||
| if json.len() > 50_000 { | ||
| format!( | ||
| "Found {} tasks (output truncated to 50KB). \ | ||
| Use column= filter to narrow results.\n{}", | ||
| tasks.len(), | ||
| &json[..50_000] | ||
| ) | ||
| } else { | ||
| json | ||
| } | ||
| } |
There was a problem hiding this comment.
Tool output sanitization is incomplete before returning data to the LLM.
This path truncates to 50KB, but it does not strip base64 URIs or hex blobs prior to ToolOutput::llm_only(...).
As per coding guidelines, "Sanitize tool results by stripping base64 URIs, hex blobs, and truncating to 50KB before passing to LLM".
Also applies to: 197-197
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/tools/task.rs` around lines 120 - 131, The JSON returned from
serde_json::to_string_pretty(&tasks) is only truncated to 50KB but not
sanitized; before calling ToolOutput::llm_only(...) replace/strip base64 data
URIs and long hex blobs in the tasks payload (e.g., detect patterns like
data:...;base64, and long hex sequences) with a short placeholder such as
"<stripped_blob>", then serialize
(serde_json::to_string_pretty(&tasks_sanitized)) and truncate to 50KB if needed;
apply the same sanitization logic wherever a similar path uses
serde_json::to_string_pretty and ToolOutput::llm_only (the other occurrence
noted around the second block).
| if json.len() > 50_000 { | ||
| format!( | ||
| "Found {} tasks (output truncated to 50KB). \ | ||
| Use column= filter to narrow results.\n{}", | ||
| tasks.len(), | ||
| &json[..50_000] | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Rust string slicing panic UTF-8 character boundary
💡 Result:
Rust String/&str is UTF-8, so its indices in &s[a..b] are byte offsets, not “character positions”. If a or b lands in the middle of a multi-byte UTF-8 code point, Rust rejects creating an invalid &str and the slice operation panics (e.g., “byte index N is not a char boundary; it is inside …”). [1][2]
How to avoid the panic
- Slice only on UTF-8 boundaries (byte indices that start/end a code point):
- Check:
s.is_char_boundary(i)before slicing. [2]
- Check:
- Compute safe boundaries from characters:
- Use
char_indices()to get the byte offsets of eachchar, then slice using those offsets. [3]
- Use
- If you need “user-perceived characters” (grapheme clusters), use a crate such as
unicode-segmentationrather than byte/charslicing. [2]
Example
let s = "Здравствуйте";
let i = s.char_indices().nth(3).unwrap().0; // byte index at 4th char boundary
let j = s.char_indices().nth(6).unwrap().0; // byte index at 7th char boundary
let sub = &s[i..j]; // safe(Using raw byte ranges like &s[0..3] may panic if 3 is not a boundary.) [1][2]
Sources
[1] TRPL (Rust Book) on string slicing and UTF-8 byte indices/panics: (trpl.rantai.dev)
[2] Rust for C-Programmers on UTF-8 boundaries and slicing panics; mentions unicode-segmentation: (rust-for-c-programmers.com)
[3] Example approach using char_indices() to find valid slice points: (slingacademy.com)
Citations:
- 1: https://trpl.rantai.dev/docs/part-iv/chapter-30/?utm_source=openai
- 2: https://www.rust-for-c-programmers.com/ch18/18_3_the_string_type.html?utm_source=openai
- 3: https://www.slingacademy.com/article/rust-slicing-strings-correctly-to-avoid-panic/?utm_source=openai
🏁 Script executed:
find . -name "task.rs" -path "*/tools/*" -type fRepository: qhkm/zeptoclaw
Length of output: 77
🏁 Script executed:
cat -n src/tools/task.rs | sed -n '110,140p'Repository: qhkm/zeptoclaw
Length of output: 1502
Fix UTF-8 panic risk and add required sanitization.
&json[..50_000] panics if the index lands mid-UTF-8 character. Additionally, per coding guidelines, tool results must sanitize base64 URIs and hex blobs before truncation.
💡 Suggested fix
if json.len() > 50_000 {
+ // Strip base64 URIs and hex blobs per tool sanitization guidelines
+ let sanitized = json
+ .replace(|c: char| c.is_whitespace() || c.is_alphanumeric() || matches!(c, ',' | ':' | '{' | '}' | '[' | ']' | '"'), "")
+ .chars()
+ .filter(|c| !c.is_control())
+ .collect::<String>();
+
+ // Safely truncate to UTF-8 boundary
+ let mut end = 50_000.min(sanitized.len());
+ while end > 0 && !sanitized.is_char_boundary(end) {
+ end -= 1;
+ }
format!(
"Found {} tasks (output truncated to 50KB). \
Use column= filter to narrow results.\n{}",
tasks.len(),
- &json[..50_000]
+ &sanitized[..end]
)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/tools/task.rs` around lines 121 - 127, The current truncation uses
&json[..50_000] which can panic across UTF‑8 codepoint boundaries and truncates
before sanitizing sensitive blobs; to fix, produce a sanitized copy of the JSON
string (scan the string and replace base64 URI and long hex-blob patterns with
masked placeholders or shortened safe forms) and then take a UTF‑8-safe prefix
from that sanitized string (either use
s.char_indices()/s.chars().take(n).collect() or check s.is_char_boundary(index)
and backtrack to the previous boundary) and use that safe, sanitized slice in
the formatted message (refer to the json variable and the tasks.len() usage in
this block).
Summary
Re-opening for proper review (original #179 was auto-merged without user approval).
src/api/) with REST routes, WebSocket event streaming, JWT auth, CSRF protection, EventBus wired into AgentLooppanel/) with 6 pages — Dashboard, Logs, Sessions, Cron/Routines, Kanban (drag-and-drop), live Agent Officezeptoclaw panelcommand with install/start/auth/uninstall subcommandsTaskToolfor kanban board operations from the agentKey files
src/api/— axum server, auth, middleware, routes, EventBus, TaskStoresrc/cli/panel.rs— CLI panel command handlerssrc/tools/task.rs— Agent-accessible kanban toolsrc/agent/loop.rs— EventBus wiring for real-time panel eventspanel/— Full React frontend (Vite + Tailwind + TypeScript)Test plan
cargo test --libpasses (2732 tests)cargo clippy -- -D warningscleancargo fmt -- --checkcleanzeptoclaw panel, verify dashboard loadsCloses #175
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes