Files
odysseus/static/js/storage.js
Kenny Van de Maele 2be3779e6e feat: Add workspace: confine agent tools to a folder (#1103)
* feat: Add workspace: confine agent tools to a folder

Pick a server folder as the agent's workspace so its file/shell tools work
there and don't touch files outside it. File tools are hard-confined; bash/
python run with cwd set to the folder.

Includes a slash command: `/workspace` (alias `/ws`) — show / `set <path>` /
`clear` / `pick` (open the directory browser).

- routes/workspace_routes.py: GET /api/workspace/browse (admin-only).
- src/tool_execution.py: hard path confinement for read_file/write_file;
  bash/python cwd. Threaded route → stream_agent_loop → execute_tool_block.
- src/agent_loop.py: workspace note prepended to the system prompt.
- static/: overflow menu item, input-bar pill, directory-browser modal, and
  the /workspace slash command.
- tests/test_workspace_confine.py.

* Wire workspace confinement into tools that landed after this PR

edit_file (#1239) and grep/glob/ls (#1670) merged after workspace-confine was
written, so they bypassed the workspace boundary. Thread the workspace through:
  - edit_file: _do_edit_file resolves via _resolve_tool_path_in_workspace
  - grep/glob/ls: _resolve_search_root confines to the workspace (root + paths)
  - bash/python/bg cwd: workspace or _AGENT_WORKDIR (keep the #2586 data-dir
    default when no workspace is set)
Tests cover edit_file + grep/ls confinement (inside ok, outside rejected).

* Workspace picker: editable path bar + modal style cohesion + cross-platform hardening

- Make the current-folder strip an editable address bar: type/paste a full
  path and press Enter to navigate (also reaches other Windows drives and
  hidden dirs the up-only browser cannot).
- Reuse shared modal CSS: drop bespoke .workspace-modal-content/.workspace-btn*
  in favour of base .modal-content/.modal-body and the .confirm-btn button
  family; separators/hover use var(--border). Net -31 CSS lines.
- Fix the path field overflowing the modal right edge (flex stretch + margin
  vs an overflow:auto scrollbar-feedback loop): full-bleed, no h-margin.
- Cross-platform confinement: normcase the workspace commonpath check so
  containment holds on case-insensitive filesystems (Windows/macOS).
- Make tests OS-portable: sibling temp dirs instead of /etc, python os.getcwd()
  instead of pwd. 5 pass.
2026-06-05 00:06:37 +02:00

125 lines
3.0 KiB
JavaScript

// static/js/storage.js
// Centralized localStorage access with key constants and JSON parse safety
// ── Key constants ──
export const KEYS = {
THEME: 'odysseus-theme',
TOGGLES: 'odysseus-toggles',
SIDEBAR_COLLAPSED: 'sidebar-collapsed',
SIDEBAR_WIDTH: 'sidebar-width',
SIDEBAR_SIDE: 'sidebar-side',
CURRENT_SESSION: 'currentSessionId',
COMPARE_SAVE: 'compare-save-results',
COMPARE_CHAT: 'compare-continue-chat',
COMPARE_BLIND: 'compare-blind',
COMPARE_RANDOM: 'compare-randomize',
MODELS_EXPANDED: 'odysseus-model-expanded',
MODEL_ENDPOINTS: 'odysseus-model-endpoints',
MODEL_SELECTED: 'odysseus-selected-model',
SORT_ORDER: 'odysseus-sessions-sort',
CHAT_SEARCH_SCOPE: 'odysseus-search-scope',
INCOGNITO: 'odysseus-incognito',
RAG_ACTIVE: 'odysseus-rag-active',
MCP_ACTIVE: 'odysseus-mcp-active',
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density',
WORKSPACE: 'odysseus-workspace'
};
/**
* Safely get and parse a JSON value from localStorage.
* Returns fallback on any error.
*/
export function getJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback !== undefined ? fallback : null;
return JSON.parse(raw);
} catch (e) {
console.warn('[Storage] Failed to parse key "' + key + '":', e.message);
return fallback !== undefined ? fallback : null;
}
}
/**
* Set a JSON-serialized value in localStorage.
*/
export function setJSON(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.warn('[Storage] Failed to set key "' + key + '":', e.message);
}
}
/**
* Get a raw string value from localStorage.
*/
export function get(key, fallback) {
try {
const val = localStorage.getItem(key);
return val !== null ? val : (fallback !== undefined ? fallback : null);
} catch (e) {
return fallback !== undefined ? fallback : null;
}
}
/**
* Set a raw string value in localStorage.
*/
export function set(key, value) {
try {
localStorage.setItem(key, value);
} catch (e) {
console.warn('[Storage] Failed to set key "' + key + '":', e.message);
}
}
/**
* Remove a key from localStorage.
*/
export function remove(key) {
try {
localStorage.removeItem(key);
} catch (e) {
// Ignore removal errors
}
}
// ── Toggle state helpers ──
export function loadToggleState() {
return getJSON(KEYS.TOGGLES, {});
}
export function saveToggleState(state) {
setJSON(KEYS.TOGGLES, state);
}
export function getToggle(name, fallback) {
const state = loadToggleState();
return state[name] !== undefined ? state[name] : (fallback !== undefined ? fallback : false);
}
export function setToggle(name, value) {
const state = loadToggleState();
state[name] = value;
saveToggleState(state);
}
const Storage = {
KEYS,
getJSON,
setJSON,
get,
set,
remove,
loadToggleState,
saveToggleState,
getToggle,
setToggle
};
export default Storage;