[FEATURE] UI - #1
Merged
Merged
Conversation
Introduce a browser-based UI for configuring, testing, and browsing
tools. Starts a local HTTP server and opens the browser — like
Postman for governed tool orchestration.
Command:
- `factorly ui` starts server on localhost:3741, opens browser
- `--port` flag to customize port
- Cross-platform browser open (open/xdg-open/rundll32)
Architecture:
- Go stdlib net/http with ServeMux pattern matching (Go 1.22+)
- html/template with per-page parsing to avoid {{define "content"}}
conflicts — each page template is cloned with the shared layout
- htmx 2.x vendored locally (50KB) for dynamic interactions
- htmx SSE extension vendored locally (9KB) for future streaming
- Tailwind CSS via CDN (only external dependency)
- All assets embedded via //go:embed — single binary, no file deps
- Imports proxy/registry/config directly (same process, no API)
- fs.Sub used to strip embed directory prefix for static serving
Pages:
- /tools — list all configured tools with type, description, and
oversight rules (shadow summary badges)
- /tools/{name} — tool editor showing type-specific config (CLI:
command/args, REST: method/url/path, MCP: command/url, workflow:
step list), parameters form, oversight summary
- /tools/{name}/try — POST endpoint that executes the tool through
the full proxy (parameter validation, shadow policy, logging,
output filtering) and returns HTML result inline via htmx
- /templates — grid of 39 templates grouped by category with
auth type badges
- /templates/{name} — detail view with tools included, auth guide,
vault key, and full YAML preview
- /vault — placeholder page (coming soon)
Try It flow:
- User fills parameter form, clicks Run
- htmx POSTs form data, shows "running..." indicator
- Server calls proxy.ExecuteWithContext with interface "ui"
- Returns color-coded HTML result: green for success, red for
blocked/denied, amber for tool errors
- Output HTML-escaped to prevent XSS from tool results
- Duration shown in milliseconds
Template functions:
- shadowSummary: renders ShadowConfig as concise badge text
- templateCategories: extracts unique categories from templates
- inc: increments integer for step numbering
Files:
- cmd/factorly/ui_cmd.go — cobra command + browser open
- internal/ui/server.go — Server struct, routes, template parsing
- internal/ui/handlers_tools.go — tools list, editor, try-it, render
- internal/ui/handlers_templates.go — template browser + detail
- internal/ui/handlers_vault.go — vault placeholder
- internal/ui/embed.go — //go:embed directives
- internal/ui/sse.go — SSE writer (for future workflow streaming)
- internal/ui/static/ — htmx.min.js, sse.js, style.css
- internal/ui/templates/ — 7 HTML templates + partials dir
…nstall
The initial UI commit was scaffolding only — forms didn't submit,
vault wasn't wired, dynamic fields 404'd. This makes everything
actually work.
Critical fixes:
- Wire vault to UI server: call getCachedVault() after
bootstrapProviders() (cached singleton, won't re-prompt) and
pass to ui.Options.Vault. Tools with vault refs now execute
correctly from "Try it" panel.
- Add /tools/_form handler returning type-specific HTML partials
(CLI: command+args+stdin, REST: method+url+path, Workflow: info)
swapped via htmx on type dropdown change in new tool form
Tool CRUD (handlers_tools.go + yamlio.go):
- handleToolNew: renders new tool form
- handleToolCreate: POST /tools/_new, builds ToolConfig from form,
writes YAML via SaveTool (prefers tools_dir/, falls back to
main config), redirects to editor
- handleToolSave: POST /tools/{name}, updates all type-specific
fields (CLI: command/args/stdin, REST: method/url/path/body,
MCP: command/args), writes to YAML
- handleToolDelete: DELETE /tools/{name}, removes from disk and
in-memory config, returns HX-Redirect header
- handleToolFormPartial: GET /tools/_form?type=X, returns HTML
partial for the selected tool type
- splitArgs: space-separated arg parser respecting quotes
- yamlio.go: SaveTool/DeleteTool with tools_dir vs main config
strategies, generic map-based YAML preservation
Tool edit form (tool_edit.html):
- Full editable form with type-specific fields always visible
(removed {{if .Tool.Stdin}} / {{if .Tool.Body}} conditionals
that hid empty fields)
- Delete button with hx-confirm dialog
- Save button persists to YAML
- joinArgs template function for rendering args back to string
- "New Tool" button added to tools list page
Vault management (handlers_vault.go + vault.html):
- handleVault: lists keys (never values), shows "not available"
if no vault backend
- handleVaultSet: POST /vault with key+value, returns updated
key list as htmx partial
- handleVaultDelete: DELETE /vault/{key} with confirm, returns
updated list
- renderVaultKeys: error handling (shows error message instead
of silently rendering empty list)
- Password inputs for secret values, ••••••• placeholders
Workflow builder (handlers_workflows.go + workflow_edit.html):
- handleWorkflowEdit: shows step list with tool dropdowns
populated from non-workflow tools, store fields, run panel
- handleWorkflowSave: POST rebuilds steps from form indices,
saves to YAML
- handleWorkflowRun: executes through proxy, parses JSON output,
renders structured result with per-step status icons (✓/✗/—),
duration, and error details
- JavaScript addStep/removeStep: clone step rows from dropdown
options, auto-renumber indices on remove
- Workflow tools redirect from /tools/{name} to /workflows/{name}
Template install (handlers_templates.go + template_detail.html):
- handleTemplateInstall: POST /templates/{name}/install, gets
full YAML via FilterYAML, writes to tools_dir/ or merges into
main config, redirects to /tools
- "Install Template" button on detail page
Route registration (server.go):
- GET /tools/_form (type partials)
- GET /tools/new, POST /tools/_new (create)
- POST /tools/{name} (save), DELETE /tools/{name} (delete)
- POST /templates/{name}/install
- GET /workflows/{name}, POST /workflows/{name}/save,
POST /workflows/{name}/run
- POST /vault, DELETE /vault/{key}
Make the UI feel like Postman: formatted responses, syntax highlighting, persistent tool sidebar, and a clear Send → Result feedback loop. Response panel (the "Send" moment): - Dark terminal-style response area (bg-gray-900) matching Postman's aesthetic for output display - Status bar with colored icon (✓ green, ✗ red), tool name, and duration in milliseconds - JSON auto-detection: pretty-prints compact JSON with indentation, then syntax-highlights (keys=indigo, strings=green, numbers=amber, booleans=amber, brackets=gray, null=gray) - Response/Raw tabs — toggle between highlighted formatted view and plain unformatted text via inline script - Blocked responses: red status bar + error on dark background - "▶ Send" button (renamed from "Run") with animated pulse indicator during execution - formatJSONHTML: custom syntax highlighter that walks the JSON string and wraps tokens in colored spans (no external library) Persistent sidebar: - 240px left sidebar with all tools listed, visible on all tools pages - Type-colored dots: gray=CLI, blue=REST, purple=MCP, green=workflow - Active tool highlighted with indigo right border + light background - Filter/search input at top (instant client-side filtering via JS) - "+ New Tool" button in sidebar footer - Sidebar only renders on Nav="tools" pages (templates/vault get full width) - getSidebarTools() auto-injected via render() when Nav is "tools" - ActiveTool passed from handleToolEdit for highlight state Layout restructure: - Body switched to flexbox column (nav + content area) - Content area is flex row (sidebar + main), both overflow-y-auto - Sidebar shrink-0, main flex-1 - tools.html simplified: shows "← Select a tool" when sidebar is present (tool list now lives in sidebar, not main content) - Removed "← Back to tools" link from tool editor (sidebar handles navigation) Try It panel polish: - Header shows tool name in monospace on the right - Send button: larger, rounded-lg, shadow, font-medium - Loading indicator: animated pulse dot + "running..." text
Workflow pipeline view (replaces flat step list):
- Steps rendered as cards connected by vertical lines, visually
showing the pipeline flow from top to bottom
- Condition labels on connectors: `if:` shown as blue pill,
`require:` shown as amber pill between cards
- Step cards show: numbered circle badge, tool name as inline
dropdown, store variable as editable indigo badge, switch cases
as indented branch lines, param names as gray pills
- Remove button (✕) appears on hover with opacity transition
- Add step appends card with connector, removes empty state
- Remove step cleans up connector + condition label, renumbers
- 3-column pipeline / 2-column run panel layout (was 50/50)
- Run panel sticky-positioned, full-width green "▶ Run Pipeline"
button with animated loading indicator
- ActiveTool passed for sidebar highlighting on workflow pages
Workflow run result redesign:
- Status bar matching tool response panel style (rounded icon +
status + duration)
- Step list with colored circle icons (green ✓, red ✗, gray —)
+ tool name + duration per step
- Errors shown inline under failed steps in red monospace
- Result output on dark background (bg-gray-900, green text)
- Consistent visual language across tool and workflow responses
History page (/history):
- Reads last 100 entries from ~/.config/factorly/calls.jsonl
(same audit log as `factorly logs`)
- Entries shown in reverse chronological order (most recent first)
- Each entry: status icon badge, tool name, shadow action pill,
duration, relative timestamp ("just now", "3m ago", "2h ago")
- Click to expand (HTML details element) showing: full timestamp,
interface, status, duration, agent ID, shadow action
- Parameters displayed as name above code block (pre with gray
background, monospace, whitespace-pre-wrap for long values)
- Error shown in red background block
- Output shown on dark terminal background (truncated to 200 chars)
- Filter by tool name (live htmx search, 300ms debounce)
- Filter by status dropdown (all/success/error/blocked)
- Empty state when no logs exist
Navigation:
- Added "History" to nav bar (Tools | Templates | History | Vault)
- Registered GET /history route and history.html template
…nd htmx-boost navigation
Icons:
- Add `icon` template function with 12 Lucide SVG icons rendered
inline (play, send, plus, trash, check, x, shield, terminal,
globe, workflow, clock, lock) — no external font/CDN dependency
- Add CSS rule `button svg, a svg { display: inline-block; vertical-align: middle }`
to prevent SVGs breaking button text onto two lines
- Replace ▶ unicode with {{icon "send"}} and {{icon "play"}} in
tool and workflow buttons
Tool editor — full config editing:
- Add collapsible Parameters section: each param editable as a card
with name, type dropdown, required checkbox, default, description;
add/remove params dynamically; count shown in section header
- Add collapsible Oversight section: deny list (comma-separated),
confirm checkbox, rate limit input
- Add collapsible Advanced section: timeout, max output bytes,
compress hints
- Add `joinList` template function for rendering string slices
- Update handleToolSave to parse all new fields: parameters with
name/type/required/default/description, shadow config from
deny/confirm/rate_limit form fields, timeout, max_output, compress
- Add `splitComma` helper for parsing comma-separated inputs
- Add `strconv` import for max_output parsing
Workflow steps — compact with full config:
- Replace card pipeline with collapsible details/summary rows
- Collapsed: step number + tool name + store badge + condition
indicators (if/req pills) + remove button — all on one line
- Expanded: full editing form for tool (dropdown), store, if
condition, require condition, and params (key/value list with
add/remove)
- handleWorkflowSave updated to parse step_if_N, step_require_N,
and step_param_key_N[]/step_param_val_N[] form arrays
- New steps open expanded for immediate configuration
Navigation and save UX:
- Add hx-boost="true" on body — all link clicks and form submits
use htmx automatically (AJAX page swap, no full browser refresh,
URL still updates via history push, back button works)
- Tool save form uses hx-post with inline "✓ Saved" confirmation
instead of redirect + full page reload
- Fix configPath not persisted: loadConfig() now sets the global
configPath when auto-discovering via FindConfig(), so UI writes
go to the correct file
Visual polish:
- Type-specific header previews: CLI terminal (dark bg, traffic
lights, green $ command), REST method badge + URL bar, MCP
purple hex with command
- Colored type badges: CLI=gray, REST=blue, MCP=purple
- Oversight indicator bar (amber) shown when shadow config exists
- Parameter table with Name/Type/Required/Default columns
- REST method + base URL on same row
- Try It panel: uppercase tracking-wide header, full-width button,
type shown next to param name
Tool save fix: - Persist resolved configPath in loadConfig() when auto-discovered via FindConfig() — fixes "open : no such file" error when saving tools from the UI without --config flag - Tool save form uses hx-post with inline "✓ Saved" flash instead of full-page redirect, targets #save-status span next to button Full tool config editing (handleToolSave): - Parse parameters from form: name, type, required, default, description per param (indexed param_name_N, param_type_N, etc.) - Parse shadow/oversight: deny (comma-separated), confirm checkbox, rate_limit field — constructs ShadowConfig or clears to nil - Parse advanced fields: timeout, max_output (strconv), compress (comma-separated via splitComma helper) - Add splitComma utility for parsing "all, json, logs" style inputs - Add strconv import Full workflow step config editing (handleWorkflowSave): - Parse step_if_N and step_require_N condition fields per step - Parse step_param_key_N[] and step_param_val_N[] arrays for per-step params, building map[string]string from paired arrays - Steps save with all fields: tool, store, if, require, params Template functions: - Add joinList (strings.Join with ", ") for rendering []string fields in form values (deny list, compress hints) Layout: - Add hx-boost="true" on body — all navigation uses htmx (AJAX page swap without full browser refresh, URL updates via history push, back button works) Tool editor template: - Collapsible Parameters section with add/remove param cards (name, type dropdown, required checkbox, default, description) - Collapsible Oversight section (deny, confirm, rate_limit) - Collapsible Advanced section (timeout, max_output, compress) - Type-specific previews: CLI terminal, REST method+URL, MCP hex - Save button with inline #save-status confirmation area - Form uses hx-post instead of method="POST" action="..." Workflow editor template: - Steps as collapsible details/summary: compact summary row (number + tool + store + condition pills), expanded form with tool dropdown, store, if, require, and params (key/value list with add/remove)
Harden the localhost UI server against cross-origin attacks: Loopback binding: - Bind to 127.0.0.1:<port> explicitly instead of localhost (avoids potential IPv6 resolution or hostname override) - Open browser to http://localhost:<port>/?token=<nonce> Host header validation: - Middleware rejects requests where Host header (port stripped) is not localhost or 127.0.0.1 - Returns 403 Forbidden for unexpected hosts - Prevents DNS rebinding attacks where a malicious domain resolves to 127.0.0.1 and attempts to reach the local UI server Per-run token authentication: - generateNonce() creates a 32-char crypto/rand hex token unique to each process run — never persisted to disk - Browser opened with ?token=<nonce> in URL - First request with valid token: sets HttpOnly SameSite=Strict cookie (factorly_session) and redirects to strip token from the address bar - Subsequent requests validated via cookie only - Static assets (/static/*) exempt from token check (no sensitive data, needed for CSS/JS loading before cookie is set) - Requests without valid cookie or token get 401 Unauthorized - SameSite=Strict cookie prevents cross-origin sites from sending it, blocking CSRF-style attacks against the local server Handler extraction: - Add Server.Handler() method returning the inner http.Handler so ui_cmd.go can wrap it with security middleware before serving - ui_cmd now uses http.ListenAndServe directly with the wrapped handler chain: hostValidation → tokenValidation → UI handler
Auth configuration UI:
- Tool editor: replace read-only auth display with editable form —
auth type dropdown (None/Bearer/Custom header/OAuth) with dynamic
fields that toggle via JS based on selection
- Bearer: token input accepting {{vault:KEY}} references
- Custom header: header name + value inputs
- OAuth: provider name input (references oauth_providers config)
- New tool form: auth section added to REST type partial with same
dropdown + dynamic fields via toggleNewAuthFields()
- handleToolSave: parse auth_type, auth_token, auth_header,
auth_value, auth_provider from form; construct AuthConfig or
clear to nil when "None" selected
- handleToolCreate: same auth parsing for new tool creation
Template fixes:
- Escape {{vault:KEY}} in placeholder attributes using
{{"{{vault:KEY}}"}} to prevent Go template parser from
interpreting them as function calls
Lint fixes:
- Remove unused const uiHost (switched to localhost literals)
- Fix whitespace alignment in hostValidation allowed map
- Reformat icon map keys to consistent alignment (golangci-lint)
Vault page redesign: - Display project vault and global vault as separate labeled sections, each with their own key count header - Detect vault type from cached backend: if FallbackBackend, extract Primary (project) and Secondary (global) fields; if single vault, determine type from file existence - No extra password prompts — reuses the already-opened cached vault from bootstrapProviders, just splits the view Scope-aware operations: - "Store in" dropdown on the add form lets user choose which vault (project/global) to write new secrets to - Delete buttons pass ?scope= query param to target correct vault - resolveVaultBackend(scope) routes to project/global/fallback - renderVaultKeys rebuilds both sections for htmx partial swap Replace value: - Each key row now has a "replace" button (indigo, between dots and delete) - Click shows inline password input + Save/Cancel below the key - Submit POSTs to /vault with same key + new value (Set overwrites) - Form injected via JS with htmx.process() for dynamic htmx attrs - Cancel removes the form without touching the vault Server changes: - Add ProjectVault and GlobalVault fields to Options and Server - ui_cmd.go: detect vault type without re-opening (no extra password prompts), pass both backends to UI server - Add os and path/filepath imports for vault path detection - vaultSection struct: Label, Keys, Scope for template rendering Template: - Sections rendered with bg-gray-50 headers showing vault name and key count - Empty sections show "empty" placeholder - Fallback to single "Vault" section if no separate backends
Embeds tailwind into local so we can run offline
Live activity feed that shows tool calls in real-time as agents make them — the core "watch your agent work" feature. Proxy event emitter (proxy.go): - Add CallEvent struct: timestamp, tool, params, status, duration, shadow action, agent ID, output (truncated to 500 chars), error - Add onCall callback field to Proxy, set via WithOnCall option or SetOnCall method (for post-creation wiring) - emitCallEvent fires after every tool call: successful executions, validation blocks, shadow denials, and guard blocks — all four log paths in ExecuteWithContext emit events Activity broadcaster (handlers_activity.go): - ActivityBroadcaster fans out CallEvents to all connected SSE clients via a map of channels protected by mutex - subscribe/unsubscribe manage client lifecycle - Broadcast sends to all clients non-blocking (skips slow clients) - GET /activity/stream — SSE endpoint holding connection open, pushing JSON events with tool, status, duration, timestamp, params, output, shadow action, agent ID - GET /activity — renders the activity page Activity page (activity.html): - Green pulsing "live" badge + call counter - JavaScript EventSource connects to /activity/stream - Events rendered newest-first as expandable details/summary rows - Collapsed: status icon (✓/✗/!) + tool name + shadow badge + duration + timestamp - Expanded: parameters (key above code block), error (red bg), output (dark terminal bg, truncated, scrollable) - Clear button resets the feed - "Waiting for agent activity..." placeholder until first event - Auto-reconnects on SSE disconnect Wiring (ui_cmd.go): - Create ActivityBroadcaster, wire to proxy via p.SetOnCall - Pass broadcaster to UI server via Options.Activity - Import proxy package for CallEvent type Server (server.go): - Add activity field to Server struct and Options - Register /activity and /activity/stream routes - Add activity.html to template parse list Nav: Tools | Templates | Activity | History | Auth | Vault
Replace the dedicated /activity page with a persistent right-side drawer accessible from any page via a header toggle button. The drawer shows real-time SSE tool call events with expandable details (params, output, errors), a live call counter badge, and clear functionality. Key changes: - Add activity drawer (384px, slide-in/out with CSS transform) to layout - Add Activity toggle button with pulsing green dot in header nav - Remove "Activity" from main nav links, redirect /activity to /tools - Fix hx-boost JS redeclaration bugs with guarded var declarations - Make SSE EventSource a singleton (window._activitySSE) to survive hx-boost page navigations without creating duplicate connections - Show expandable call details: status icon, tool name, duration, timestamp, params, output/error with syntax-appropriate styling - Flash activity dot indigo on new events for visual feedback
… fix YAML ordering
Promote workflows from a sub-feature of tools to their own top-level
navigation section with dedicated sidebar, list view, create/edit/delete
flows. Move templates out of the nav into a "Browse Templates" button
in the tools empty state. Add inline rename/description editing with
toggle (pencil icon → form → Save/Cancel) for both tools and workflows.
Navigation changes:
- Nav order: Tools, Workflows, Auth, Vault, History
- Templates removed from nav, accessible via button in tools section
- Workflows get their own sidebar with filter and "+ New Workflow" button
Tool/Workflow rename:
- Dedicated /tools/{name}/rename and /workflows/{name}/rename endpoints
- Toggle between static header and inline edit form (stacked fields)
- Description shown on its own line below title in both sections
YAML serialization fixes:
- Fix duplicate tool error when saving to tools_dir (now removes inline
definition from factorly.yaml before writing to dir)
- Fix field ordering in saveToolToConfig: switch from map[string]any
round-trip (alphabetizes keys) to yaml.Node manipulation (preserves
struct field order, keeping "type:" first)
- DeleteTool now cleans up from both inline config and dir file
- Force block style on tools mapping for readable multi-line output
New files:
- templates/workflows.html — workflow list/empty state
- templates/workflow_new.html — create workflow form (name + description)
- yamlio_test.go — tests for type-first ordering and no-duplicate behavior
…P tool creation Fix critical bugs in YAML I/O where tools defined in multi-tool files (e.g., changelog.yaml with git.log, git.commit_prep, git.status) would get duplicated on save — SaveTool now finds and updates tools in-place within their original file. Also fix toolsDir resolution to auto-discover .factorly/tools/ convention when tools_dir isn't explicitly configured. YAML I/O fixes: - saveToolToDir: find existing tool in any dir file before creating new one - updateToolInFile: update single tool in multi-tool file using yaml.Node - deleteToolFromDir: remove from multi-tool files, delete file if empty - resolveToolsDir: auto-discover .factorly/tools/ when tools_dir unset, resolve relative paths against config file location Workflow step reordering: - Add ▲/▼ arrows to each step row for move up/down - Swap values between rows (not DOM nodes) to avoid browser select quirks - Update summary labels after swap to reflect new tool names - Fix addParam to read step index from DOM instead of hardcoded template value - Scope reindexSteps to #steps-list container MCP tool creation: - Add MCP option to tool type dropdown in new tool form - Add form partial with Command, Args, and URL (HTTP transport) fields - Handle MCP type in tool create handler (command, args, url) Tests: - TestSaveToolToDir_MultiToolFile: update in-place without creating new file - TestDeleteToolFromDir_MultiToolFile: remove one tool, keep others
Add create, edit, and delete capabilities for OAuth providers directly
from the web UI, replacing the previous read-only view that required
manual config file editing.
Auth page changes:
- Add "+ New Provider" button with toggle form (name, client ID/secret,
auth/token URLs, scopes) supporting {{vault:KEY}} references
- Add inline edit form per provider (click "edit" to expand, pre-filled)
- Add "delete" button that removes provider config AND vault token
- Separate "logout" (token only) from "delete" (provider + token)
- Route split: DELETE /auth/{provider} deletes config, DELETE
/auth/{provider}/token just removes the token
YAML I/O:
- Add generic upsertConfigMapEntry/deleteConfigMapEntry helpers for any
top-level YAML mapping using yaml.Node (preserves ordering/structure)
- SaveOAuthProvider/DeleteOAuthProvider wrappers for oauth_providers map
Handler changes:
- handleAuthCreate: POST /auth/_new, validates name, parses scopes
- handleAuthUpdate: POST /auth/{provider}, updates existing provider
- handleAuthDelete: DELETE /auth/{provider}, removes config + token
- authProviderView extended with ClientID, ClientSecret, AuthURL,
TokenURL fields for pre-filling edit forms
…tion Add a complete import-from-OpenAPI flow to the UI, allowing users to generate REST tool definitions from any OpenAPI 3.x spec URL or file path with preview, selective import, and proper tool name sanitization. Import flow: - /tools/import page with spec URL input and optional name prefix - Preview step (htmx partial) shows generated tools with method badges, names, and descriptions in a scrollable checklist - Select all/none checkbox in header for bulk toggle - Filter textbox for narrowing down large specs - Confirm step saves selected tools to tools_dir (single file) or inline config Tool name sanitization (openapi.go): - Fix: operationIds containing "/" (e.g., "actions/list-workflow-runs") broke URL routing since "/" is a path separator - Replace "/" with "." and spaces with "_" in operationId to produce valid dot-separated names (e.g., "github.actions.list-workflow-runs") UI integration: - "Import from OpenAPI" / "Import OpenAPI" buttons in tools empty state and select-a-tool state - Title tag format updated: "Page — Factorly" instead of "Factorly — Page" Tests: - 4 import handler tests (page render, empty URL, invalid path, no selection) - 4 buildToolName tests confirming slash sanitization
Address all CodeQL security findings: add centralized path sanitization,
fix open URL redirect, set Secure cookie flag, and auto-refresh Try It
panel after tool save.
Path sanitization:
- Add safePath() function that rejects traversal (..), path separators
(/ \), empty strings, and dot-only names — single source of truth for
all user-provided values used in file paths
- toolFilename() now returns (string, error), delegates to safePath()
- Import handler validates prefix with safePath() before building path
- writeFileCreate() uses filepath.Dir() instead of strings.LastIndex()
to extract parent directory safely
Cookie security (ui_cmd.go):
- Set Secure flag on session cookie when request is over TLS
(Secure: r.TLS != nil)
Open redirect fix (ui_cmd.go):
- Token-stripping redirect now uses r.URL.EscapedPath() (local path
only) instead of clean.String() (full URL including host/scheme),
preventing redirect to attacker-controlled external URLs
Try It panel refresh:
- Save form fires hx-on::after-request to reload the Try It panel
via GET /tools/{name}/try-panel after successful save
- New handleToolTryPanel handler renders just the try-it form HTML
with current params, so param changes reflect immediately
Tests:
- TestSafePath: 10 cases covering valid names, empty, dots, traversal,
separators
Address remaining CodeQL security findings in openapi spec loading and REST provider file parameter handling. OpenAPI spec reader (openapi.go): - Validate URL scheme (http/https only) before fetching, preventing SSRF via crafted schemes that url.Parse might accept - Use parsed.String() (re-serialized) instead of raw user input for http.Get to ensure URL is well-formed - Limit response body read to 10MB via io.LimitReader to prevent resource exhaustion from oversized specs - Resolve file paths to absolute via filepath.Abs() before os.ReadFile to normalize traversal patterns REST provider file param (rest.go): - Resolve in:file parameter to absolute path via filepath.Abs() before os.Open and os.Stat, making path handling explicit and unambiguous - Added #nosec annotations documenting that file paths are intentionally user-provided tool parameters Tests: - TestReadSpec_RejectsInvalidScheme: ftp:// falls through to file read - TestReadSpec_RelativePath: valid absolute path reads correctly - TestReadSpec_TraversalPath: ../../../etc/passwd resolves and fails - TestGenerate_HTTPURLValidation: unreachable URL fails gracefully - TestRESTFileParamRelativePath: file upload works through filepath.Abs - Fix error message assertion in TestRESTFileParamMissing
…up fix
Enable imported and created tools to be immediately executable from the
UI without server restart. Fix OpenAPI import to extract path parameters
from URL placeholders. Fix config loading duplicate when tools_dir
points to the auto-discovered .factorly/tools/ directory.
Runtime provider registration:
- Add AddTool/RemoveTool to CLIProvider and RESTProvider for dynamic
tool registration at runtime
- Add Proxy.Provider() getter to access registered providers by key
- Server.registerProvider() converts config.ToolConfig to provider-
specific definitions (CLIToolDef/RESTToolDef) and adds to provider
- Auto-creates CLI/REST provider if none exists (first tool of that type)
- Server.unregisterTool() removes from both registry and provider
- Wired into all mutation points: create, save, rename, delete, import
OpenAPI path parameter extraction:
- Add ensurePathParams(): scan {param} placeholders in path and generate
required path params for any not explicitly listed in parameters
- Add mergeParams(): merge path-level parameters (shared across methods)
with operation-level params, operation takes precedence
- Fixes imported tools missing required path params like {username}
Config dedup fix:
- Skip auto-discovery of .factorly/tools/ when it matches the resolved
tools_dir value, preventing duplicate tool loading
UI flag:
- Add --no-launch flag to skip auto-opening browser on factorly ui
Tests:
- TestToolCreate_RegistersInRegistryAndProvider (CLI provider created)
- TestToolCreate_REST_RegistersProvider (REST provider created on-the-fly)
- TestToolDelete_UnregistersFromRegistryAndProvider
- TestToolRename_UpdatesRegistryAndProvider
- TestToolSave_UpdatesProvider (registry reflects new description)
- TestGeneratePathParams (implicit params from path placeholders)
- TestGeneratePathParamsWithExplicit (no duplicates, keeps description)
… OpenAPI import Harden network and file access across the UI and serve commands: - Bind localhost by default: Both `factorly ui` and `factorly serve` now bind to 127.0.0.1 instead of 0.0.0.0. A new `--host` flag (defaulting to 127.0.0.1) controls the bind address on both commands. Users who need network access (containers, port-forwarding) can pass `--host 0.0.0.0`. - Unify serve flags: Replace `factorly serve --http :3000` with `--host`/`--port` flags matching the `factorly ui` pattern. The legacy `--http` flag is preserved (hidden) for backwards compatibility. - Block SSRF in OpenAPI import: Add `checkSpecURL()` to block requests to cloud metadata endpoints (169.254.169.254, metadata.google.internal), localhost/loopback, and private/link-local IPs when fetching remote specs. - Restrict local file reads: Add `BaseDir` option to `GenerateOpts`. The UI import handlers pass the project directory as a sandbox root; `readSpec()` validates the resolved path (after `EvalSymlinks` to prevent symlink traversal) stays within that directory. CLI callers remain unrestricted.
…ault form on success
- Telegram template: Remove incorrect Bearer auth — Telegram uses the
bot token in the URL path, not an Authorization header. Replace
/bot{{token}}/... (which required a manual token parameter) with
/bot{{vault:TELEGRAM_BOT_TOKEN}}/... resolved at registration time.
Remove the now-unnecessary token parameter from all 6 tools.
- Resolve vault refs in REST path: Both bootstrapProviders and the UI's
registerRESTProvider now resolve {{vault:KEY}} references in path
(previously only base_url, auth, and headers were resolved). This
enables patterns like /bot{{vault:TOKEN}}/method.
- Clear vault form on submit: Add hx-on::after-request handler to reset
the Add Secret form after a successful POST, preventing accidental
double-submission with stale values.
- Relax template test: Allow REST tools without an auth block when they
use {{vault:}} references in the path (alternative auth pattern).
… vault resolver, verbose REST logging
Fix REST body building for single-param POST tools (Telegram chat_id),
add explicit body type control, preserve param location on save, unify
vault ref resolution with the shared resolver, add config reload button,
and enhance REST verbose logging.
Body type support:
- Add body_type field to ToolConfig and RESTToolDef: "json" (default),
"form", "raw"
- json: always builds JSON object from named body params (fixes single
body param being sent as raw value instead of {"chat_id":"123"})
- form: sends as application/x-www-form-urlencoded
- raw: sends first body param value as-is (for pre-formatted JSON/XML)
- Content-Type auto-set: application/json for json/raw, form-urlencoded
for form
- UI: body type dropdown (JSON/Form/Raw) next to body textarea
- 6 new body type tests: single JSON, multi JSON, typed params, form,
raw, default-is-JSON
Param in: field preservation:
- Tool save handler now parses param_in_N from form into ParamConfig.In
- UI: "in" dropdown (auto/query/path/body/header) per parameter
- Fixes tools losing in:body on save, causing POST params to go to query
Shared vault resolver:
- Replace ad-hoc resolveRefTracked (only handled {{vault:KEY}} as whole
string) with delegation to vault.Resolver.Resolve/ResolveTracked
- Supports inline refs (/bot{{vault:TOKEN}}/method), multiple backends
({{vault:}}, {{op:}}), defaults (fallback)
- Cache resolver from bootstrapProviders, pass to UI server via Options
- resolve.go rewritten: 5 functions now delegate to s.resolver
Config reload button:
- POST /reload re-reads config from disk, diffs tools, applies deltas
via registerTool/unregisterTool
- Lucide refresh-cw icon in header nav, triggers HX-Refresh on success
- Shows added/updated/removed counts
Verbose REST logging:
- Log all request headers
- Log body content with type and size (truncated at 500 chars)
- Helps debug body building issues like the Telegram chat_id bug
Add editable key/value header rows to the REST tool editor with dynamic add/remove, and verify param headers override static headers. UI changes: - Headers section in REST tool config with key/value input rows - "+ header" button to add new rows, ✕ to remove - Existing headers pre-populated from tool config - Save handler parses header_key[]/header_val[] arrays into Headers map - Empty keys skipped, empty map stored as nil (clean YAML) Tests: - TestHandleToolSave_WithHeaders: save with Accept + X-Custom headers - TestHandleToolSave_HeadersCleared: saving with no headers clears old ones - TestRESTParamHeaderOverridesStatic: param with in:header overrides static header of the same name
…emory
Replace in-memory secret storage with a callback-based redaction system
that resolves vault refs on the fly at log time. Also redact hardcoded
auth credentials that don't use vault refs.
Architecture:
- Resolver.Redact(s, originalRefs) on vault.Resolver — resolves each
vault ref in originalRefs, looks up the individual secret values from
the backend, and replaces them with "****" in the input string
- RESTToolDef.RedactSecrets callback — closure capturing the resolver
and vault ref strings, no secrets stored in memory
- At registration time, collect original vault ref strings (e.g.,
"{{vault:TOKEN}}", "/bot{{vault:TOKEN}}/method") via HasVaultRefs
- At log time, RedactSecrets resolves refs and redacts the values
Auth credential redaction:
- Always redact def.Auth.Token and def.Auth.Value from verbose output
regardless of whether they came from vault (fixes integration test
TestCredentialIsolationVerboseMode for hardcoded bearer tokens)
Verbose output now includes:
- Request URL (redacted)
- All headers (redacted)
- Body content with type and size (redacted)
- File info and timeout
Tests:
- Resolver.Redact: bearer token, inline URL path ref, multiple secrets,
no refs, unresolvable ref, missing key, nil resolver (8 tests)
- RESTVerboseRedactsSecrets: secret doesn't appear in captured stderr
- RESTVerboseNoRedactWithoutSecrets: no false redaction on public tools
Always redact headers matching auth/key/secret/token/cookie patterns in verbose output, regardless of whether values came from vault refs. Fixes OAuth access tokens leaking in Authorization header since they're resolved at call time (not from vault refs tracked at boot). Changes: - Add isSensitiveHeader(): matches header names containing auth, key, secret, token, or cookie (case-insensitive) - Sensitive headers logged as "Header-Name: ****" instead of full value - Non-sensitive headers (Accept, Content-Type, X-Request-Id) logged in full as before Tests: - TestRESTVerboseRedactsSensitiveHeaders: 5 subtests verifying Authorization, X-Api-Key, X-Auth-Token, X-Secret-Id, Cookie all redacted — secret values don't appear in captured stderr - TestRESTVerboseNonSensitiveHeadersNotRedacted: Accept, Content-Type, X-Request-Id logged with full values - TestIsSensitiveHeader: 12 sensitive + 8 safe header name assertions
…ms, param descriptions
Allow empty workflows, replace client-side step generation with htmx
server rendering, add workflow input parameter editing, and show param
descriptions in Run/Try It panels.
Zero-step workflows:
- Remove validation requiring at least one step in config.Load()
- Update test to expect zero-step workflows as valid
- Save button always visible (removed {{if .Steps}} guard)
Server-rendered step addition:
- Replace client-side addStep() JS (53 lines) with htmx GET to
/workflows/{name}/add-step which returns a workflow_step partial
- New partials/workflow_step.html with full step markup including
tool select, store, if/require, params
- Tool select has "Select a tool..." empty default option
- Step index passed via hx-vals from client step count
- Tool select triggers hx-get to /workflows/{name}/step-params on
change, populating param key/value rows from the selected tool's
parameter definitions
Workflow input parameters:
- New Parameters section above Steps in workflow editor
- Name, type (string/integer/number/boolean), required, default,
description per parameter
- "+ Add" button with JS-generated rows, ✕ to remove
- Save handler parses wf_param_name_N/type/required/default/desc
into tc.Parameters
- Empty state: "No input parameters. Agents will call this workflow
without arguments."
Param descriptions in Run/Try panels:
- Workflow Run panel now shows type badge and description for each
param, matching the tool Try It panel format
…vel expr support
Extend the expression engine from condition-only (bool) to value-returning
(string) for use in step params. Add syntax for inline
transformations like jsonpath extraction, timestamps, string manipulation.
Also evaluate in the proxy so expressions work in any tool
call, not just workflows.
Expression engine (expr.go):
- Add EvalExpr(): evaluates expression, returns string result (vs
EvalCondition which returns bool). Reuses existing tokenizer/parser.
- 11 new functions in evalCall():
- now(), now(offset): UTC RFC3339 timestamp with Go duration offset
- default(var, fallback): value or fallback if empty
- upper(val), lower(val), trim(val): string transforms
- len(val): JSON array/object/string length
- substr(val, start[, end]): substring with bounds checking
- join(val, sep): join JSON array elements
- replace(val, old, new): string replacement
Workflow substitution (workflow.go):
- substituteVars() now processes patterns first via regex,
then does simple {{var}} replacement (backward compatible)
- Add SubstituteExpr(): exported function for proxy use (no vars context)
Proxy integration (proxy.go):
- ExecuteWithContext evaluates in all param values before
validation, making expressions work in any tool call (CLI, REST, MCP)
- Functions like now(), upper(), default() work everywhere; jsonpath()
and variable-dependent functions need workflow context
Documentation:
- expressions.md: new "Value expressions in step params" section with
full function reference, now() offsets, len()/join() behavior tables
- workflows.md: updated to reference syntax
Tests (42 expr + 5 now edge + 22 edge cases + 3 workflow = 72 new):
- Every function: basic + empty + edge cases
- now(): 7 tests (no offset, +24h, -1h, 30m, 1h30m, invalid, 0s)
- Edge cases: empty strings, out of bounds, no match, non-array join,
multiple replacements, nested jsonpath, array results
- Workflow integration: jsonpath extraction from stored output,
now() substitution, mixed {{var}} + in same param
…{{op:...}}
Register expr as a ResolveFunc on the vault.Resolver so all {{backend:content}}
patterns are dispatched through a single system. The proxy resolves all
patterns in one pass at call time instead of separate vault + expr handling.
Resolver changes (vault/resolver.go):
- Widen refPattern regex: key group from [A-Za-z0-9_./-]+ to .+?
(non-greedy) so expressions with parens/commas/quotes match
- Add ResolveFunc type: func(content string) (string, error) — lightweight
read-only resolver for computed backends that don't need Get/Set/Delete
- Add RegisterFunc(prefix, fn): registers alongside full backends
- Resolve() and ResolveTracked() check funcs first, then backends
- Expr funcs are NOT tracked in ResolveTracked (they're not secrets)
Proxy changes (proxy/proxy.go):
- Add resolver field and WithResolver option
- Replace manual SubstituteExpr call with resolver.Resolve() on all
params containing {{ — one pass handles both vault and expr
- Remove provider.SubstituteExpr dependency
Bootstrap (main.go):
- Register "expr" as ResolveFunc: EvalExpr(content, nil)
- Pass resolver to proxy via WithResolver
Cleanup:
- Remove SubstituteExpr from workflow.go (dead code)
- Workflow substituteVars still handles {{expr:...}} with vars context
independently for workflow-scoped variable access
Tests (7 new resolver tests):
- TestResolveFuncExpr: basic expr resolution
- TestResolveFuncWithVaultBackend: mixed vault+expr in one string
- TestResolveFuncOverridesBackend: func takes priority over backend
- TestResolveFuncNotTracked: expr funcs excluded from accessed keys
- TestResolveFuncError: error leaves pattern unchanged
- TestResolveFuncWithDefault: fallback on expr error
- TestResolveFuncComplexExpr: full expression with parens/quotes passed
…tions
6 new functions for the expression engine, bringing the total to 20.
Includes alphabetized docs, practical workflow example, and 21 new tests.
New functions:
- today(): date in YYYY-MM-DD format, with optional offset as duration
('24h') or day count ('7', '-1')
- left(val, n): first n characters with bounds clamping
- right(val, n): last n characters with bounds clamping
- find(val, needle): index of substring, returns -1 if not found
- cut(val, delim): split by delimiter → JSON array
- cut(val, delim, index): split and pick field, supports negative
indexing ('-1' for last element)
- concat(a, b, ...): variadic concatenation, works with nested
function calls like concat(upper(name), '@example.com')
Documentation:
- expressions.md: function table and detail sections alphabetized
(concat → cut → default → ... → trim → upper)
- New example 33-workflow-expressions.md: daily calendar summary,
JSON extraction, date-based API calls, string manipulation,
conditional alerting
- Added to examples/README.md
Tests (21 new):
- today: no arg, duration offset, day count, negative
- left: basic, beyond length, empty
- right: basic, beyond length, empty
- find: found, not found, empty needle
- cut: indexed (3 fields), negative index, no index (array), out of bounds
- concat: multi-arg, single, empty, nested with upper()
Updated confirmation to use modal instead of browser native
…ename
Group tools and workflows in the sidebar by their dot-separated prefix
(e.g., github.list_repos → GITHUB group). Persist collapse/expand state
in localStorage. Fix tool rename htmx target error.
Sidebar grouping (handlers_tools.go):
- Add toolGroup struct (Prefix + []toolListItem) and groupTools()
- Splits tool names on first dot to derive prefix
- Ungrouped tools (no dot) appear flat at top, groups sorted alphabetically
- render() injects SidebarToolGroups/SidebarWorkflowGroups instead of
flat lists, plus ActivePrefix for auto-opening the active group
Layout template (layout.html):
- Tools and workflows sidebars use <details> groups with prefix headers
showing group name and tool count
- Groups start open by default
- Grouped items indented (pl-7) for visual hierarchy
- Ungrouped items render flat as before
Collapse state persistence:
- localStorage key 'sidebarGroups' stores {prefix: bool} map
- On page load, restores collapsed groups
- Toggle event listener saves state on collapse/expand
- Survives hx-boost navigations and full page reloads
Filter update:
- filterTools() now hides empty groups when filtering
- Auto-expands matching groups during search
Tool rename fix:
- Changed hx-target="#tool-header-area" (nonexistent) to hx-swap="none"
- Rename handler returns HX-Redirect so no swap target needed
- Matches workflow rename pattern
UI consistency:
- Standardized button labels: "Create New Tool/Workflow/Provider"
- Added {{icon "plus"}} to add buttons across templates
… scoping Introduce a new "builtin" provider type that executes factorly's built-in tools (read_file, write_file, shell, fetch, clipboard) in-process using Go stdlib instead of forking subprocesses (cat, tee, curl, sh). Key changes: - New BuiltinProvider (internal/provider/builtin.go) with handlers for each built-in tool: os.ReadFile, os.WriteFile, exec.Command with timeout, net/http.Get with 1MB limit, and platform-aware clipboard (pbcopy/xclip) - File operations (read_file, write_file) are scoped to the project directory with path traversal prevention via scopedPath() - Shell uses platform detection (sh -c on Unix, cmd /C on Windows) - Per-tool disable via new config field `disabled_builtins: [tool_name]` - UI shows builtin tools as non-editable (emerald "Built-in" badge, locked config, info banner) with oversight/shadow still editable - Try It panel now displays result.Error for failed executions (previously only showed empty result.Output) - Added deny field description in oversight section Tests: 10 provider tests (read, write, shell, fetch, scoping, mode restrictions) + 4 new builtins tests (type verification, per-tool disable, shadow preservation).
Workflows are tools too — they now have the same configuration sections available as CLI/REST tools: - Oversight: deny patterns, confirm toggle, rate limiting - Output Filter: JSONPath extraction, head/tail/max lines, keep/strip line patterns, regex replacements - Advanced: timeout, max output bytes, compression hints, hidden toggle Also unifies tool_edit.html section layout to match workflow styling: each section (Configuration, Parameters, Oversight, Output Filter, Advanced) is now its own separated card with independent collapsible details, rather than stacked border-t sections inside one container. Save handler updated to parse and persist all new fields for workflows.
Three issues with shadow confirmation prompts: 1. MCP-originated calls only went to elicitation, never showing in the browser UI. If elicitation hung or wasn't supported, the call blocked forever. Now both channels race — browser confirm AND MCP elicitation run simultaneously, first response wins. 2. Server: single shared notify channel meant only one SSE goroutine received each signal during reconnection overlap. Replaced with a subscriber pattern (Subscribe/Unsubscribe) so all SSE connections get broadcast notifications. 3. Client: no fallback when SSE was unavailable. Added always-on polling every 2s as a safety net, immediate poll on init, and SSE reconnection with exponential backoff (1s → 15s cap). Wrapped in IIFE. The result: confirm prompts reliably appear in the browser regardless of whether the call originated from the UI, an MCP agent, or the CLI proxy. Either the browser or the agent can respond — whichever is faster.
Three issues with shadow confirmation prompts: 1. MCP-originated calls only went to elicitation, never showing in the browser UI. If elicitation hung or wasn't supported, the call blocked forever. Now both channels race — browser confirm AND MCP elicitation run simultaneously, first response wins. 2. Server: single shared notify channel meant only one SSE goroutine received each signal during reconnection overlap. Replaced with a subscriber pattern (Subscribe/Unsubscribe) so all SSE connections get broadcast notifications. 3. Client: no fallback when SSE was unavailable. Added always-on polling every 2s as a safety net, immediate poll on init, and SSE reconnection with exponential backoff (1s → 15s cap). Wrapped in IIFE. The result: confirm prompts reliably appear in the browser regardless of whether the call originated from the UI, an MCP agent, or the CLI proxy. Either the browser or the agent can respond — whichever is faster.
Documentation: - examples/26-built-in-tools.md: document in-process execution via Go stdlib, project-directory scoping for read/write, platform-aware shell (sh -c on Unix, cmd /C on Windows), disabled_builtins per-tool config, UI "Built-in" badge with locked configuration - examples/11-require-confirmation.md: document UI mode behavior (browser + elicitation race, first response wins), 60s auto-deny timeout, verbose logging showing confirmation routing and resolution source - config-reference.md: add disabled_builtins field, note builtin as an internal-only tool type Code (ui_cmd.go): - Add 60-second timeout to confirm prompts — auto-deny if no response - Add verbose logging: logs which channels are waiting (browser, elicitation), which channel responded, and whether approved/denied - Import time package for timeout duration
…00 (error) on the dark background.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Experimenting with a local UI to make it easier to manage your factorly, via
factorly ui: