2026-05-31 23:58:26 +09:00
|
|
|
// 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',
|
feat(agent): confine agent file/shell tools to a selectable workspace (#3665)
* feat(agent): workspace confinement via context-local binding + get_workspace tool
Bind the per-turn workspace once in execute_tool_block; the shared path
resolvers (_resolve_tool_path / _resolve_search_root) and the subprocess cwd
helper (agent_cwd) read it, so file tools + bash/python are confined centrally
and a new tool that uses the shared helpers cannot accidentally bypass it.
Adds the admin-gated /api/workspace/browse picker, a workspace pill + directory
modal (reusing existing modal/button CSS), the /workspace slash command, and a
get_workspace tool (replaces a system-prompt block). Confinement is OS-agnostic
(realpath/normcase/commonpath) and docker-safe (container paths, no host
assumptions). Reopens #2023.
* ux(workspace): clarify workspace is not a sandbox
Picker modal note + pill tooltip + get_workspace tool/output wording now state
plainly: read_file/write_file/edit_file/grep/glob/ls are confined to the folder,
but bash/python only start there (cwd) and are not sandboxed. Modal note reuses
the existing .muted class.
* fix(agent): treat an active workspace as file-work intent
A vague low-signal message (e.g. "look at the local project") matches no
domain keywords, so tool retrieval is skipped and only always-available tools
are offered — leaving the agent with no file access even though a workspace is
set. When a workspace is active, include the file/code tools (incl.
get_workspace) on low-signal turns so the agent can act on the folder.
Also requires the tool index (ChromaDB) to be reachable for normal retrieval;
that is an environment dependency, not part of this change.
* ux(workspace): hide pill + overflow entry in chat mode
Workspace only scopes the agent's file/shell tools, so the pill and the
overflow 'Workspace' entry are agent-only now — hidden in chat mode like the
bash toggle. Mode read from the DOM in syncWorkspaceIndicator; applyMode() is
called from the agent/chat setMode handler.
* prompt(tools): steer bash/python to defer to the dedicated file tools
bash/python schema descriptions (what native-tool-calling models read) were
bare and gave no steer, so models would do file ops via the shell (e.g. writing
SVG/HTML, which then dumps raw markup into the tool preview). Tell bash/python
in the schema + tool-index + prompt section to prefer read_file/write_file/
edit_file/grep/glob/ls and only be used for what those do not cover.
* prompt(tools): keep bash/python deferral generic (no hardcoded tool names)
Reference 'a dedicated tool' rather than listing read_file/write_file/grep/etc.
by name, so the guidance does not go stale if those tools are renamed.
* style(workspace): drop em-dashes from added code comments/strings
* ux(workspace): terser non-sandbox note in picker (no tool-name list)
* ux(workspace): mirror terse non-sandbox wording in pill tooltip
* chore: untrack local venv symlink (run-only, not part of the feature)
* prompt(workspace): keep get_workspace text generic (no hardcoded tool names)
* fix(agent): low-signal + workspace surfaces only read-only file tools
Intersect the files tool group with PLAN_MODE_READONLY_TOOLS so a vague message
in a workspace exposes read_file/grep/glob/ls/get_workspace for exploration, but
not write_file/edit_file/bash/python -- those wait for a request that actually
calls for them (RAG retrieval still adds them on a real ask).
* feat(workspace): cap browse listing at 500 dirs with a truncated hint
Mirror the filesystem_tools._CODENAV_MAX_HITS pattern with a module-local
_MAX_BROWSE_DIRS so a directory with thousands of children does not dump every
row into the picker; the response carries a truncated flag and the modal tells
the user to type a path to jump in.
* chore: untrack local venv symlink (run-only artifact)
* fix(workspace): vet the workspace root against the sensitive-path deny list at bind time
The in-workspace resolver deny-lists sensitive paths inside the workspace,
but the empty-path search root is the workspace itself, so a workspace of
~/.ssh could be listed via ls with no path. vet_workspace() (public, in
tool_execution next to the resolvers) rejects non-directories and sensitive
roots before the path is ever bound; chat_routes uses it instead of its
inline isdir check.
* fix(workspace): reject filesystem roots and stop showing rejected workspaces as active
Review findings from #3665:
P2: vet_workspace accepted / (and would accept drive/UNC roots), which makes
every absolute path 'inside' the workspace and collapses confinement into
host-wide file access. A root is its own dirname, so reject when
dirname(resolved) == resolved; the browse response now carries a selectable
flag and the picker disables 'Use this folder' on unselectable dirs.
P3: /workspace set stored any string client-side and the chat route silently
dropped rejected values, so the pill could claim a confinement that was not
in effect. New admin-gated /api/workspace/vet validates manual paths before
they persist (canonical path returned), and when a posted workspace is
rejected at send time the stream emits workspace_rejected so the client
clears the stored value and toasts instead of continuing silently.
* fix(workspace): check caller privilege before vetting the posted workspace
Review finding: /api/chat_stream called vet_workspace() on the posted value
for every caller and emitted workspace_rejected on failure, so a non-admin
who can chat but cannot use file/shell tools could distinguish existing
directories from missing/file/sensitive/root paths by whether the event
appeared. The resolution now lives in _resolve_request_workspace, which
drops the submitted value uniformly for non-admin callers, with no vetting
and no event, before the path ever touches the filesystem. Admin and
single-user behavior is unchanged. Test pins that valid and invalid paths
are indistinguishable for a non-admin and that vet_workspace is never
invoked for them.
2026-06-11 18:17:54 +02:00
|
|
|
DENSITY: 'odysseus-density',
|
2026-06-22 12:53:46 +01:00
|
|
|
UI_SCALE: 'odysseus-ui-scale',
|
feat(agent): confine agent file/shell tools to a selectable workspace (#3665)
* feat(agent): workspace confinement via context-local binding + get_workspace tool
Bind the per-turn workspace once in execute_tool_block; the shared path
resolvers (_resolve_tool_path / _resolve_search_root) and the subprocess cwd
helper (agent_cwd) read it, so file tools + bash/python are confined centrally
and a new tool that uses the shared helpers cannot accidentally bypass it.
Adds the admin-gated /api/workspace/browse picker, a workspace pill + directory
modal (reusing existing modal/button CSS), the /workspace slash command, and a
get_workspace tool (replaces a system-prompt block). Confinement is OS-agnostic
(realpath/normcase/commonpath) and docker-safe (container paths, no host
assumptions). Reopens #2023.
* ux(workspace): clarify workspace is not a sandbox
Picker modal note + pill tooltip + get_workspace tool/output wording now state
plainly: read_file/write_file/edit_file/grep/glob/ls are confined to the folder,
but bash/python only start there (cwd) and are not sandboxed. Modal note reuses
the existing .muted class.
* fix(agent): treat an active workspace as file-work intent
A vague low-signal message (e.g. "look at the local project") matches no
domain keywords, so tool retrieval is skipped and only always-available tools
are offered — leaving the agent with no file access even though a workspace is
set. When a workspace is active, include the file/code tools (incl.
get_workspace) on low-signal turns so the agent can act on the folder.
Also requires the tool index (ChromaDB) to be reachable for normal retrieval;
that is an environment dependency, not part of this change.
* ux(workspace): hide pill + overflow entry in chat mode
Workspace only scopes the agent's file/shell tools, so the pill and the
overflow 'Workspace' entry are agent-only now — hidden in chat mode like the
bash toggle. Mode read from the DOM in syncWorkspaceIndicator; applyMode() is
called from the agent/chat setMode handler.
* prompt(tools): steer bash/python to defer to the dedicated file tools
bash/python schema descriptions (what native-tool-calling models read) were
bare and gave no steer, so models would do file ops via the shell (e.g. writing
SVG/HTML, which then dumps raw markup into the tool preview). Tell bash/python
in the schema + tool-index + prompt section to prefer read_file/write_file/
edit_file/grep/glob/ls and only be used for what those do not cover.
* prompt(tools): keep bash/python deferral generic (no hardcoded tool names)
Reference 'a dedicated tool' rather than listing read_file/write_file/grep/etc.
by name, so the guidance does not go stale if those tools are renamed.
* style(workspace): drop em-dashes from added code comments/strings
* ux(workspace): terser non-sandbox note in picker (no tool-name list)
* ux(workspace): mirror terse non-sandbox wording in pill tooltip
* chore: untrack local venv symlink (run-only, not part of the feature)
* prompt(workspace): keep get_workspace text generic (no hardcoded tool names)
* fix(agent): low-signal + workspace surfaces only read-only file tools
Intersect the files tool group with PLAN_MODE_READONLY_TOOLS so a vague message
in a workspace exposes read_file/grep/glob/ls/get_workspace for exploration, but
not write_file/edit_file/bash/python -- those wait for a request that actually
calls for them (RAG retrieval still adds them on a real ask).
* feat(workspace): cap browse listing at 500 dirs with a truncated hint
Mirror the filesystem_tools._CODENAV_MAX_HITS pattern with a module-local
_MAX_BROWSE_DIRS so a directory with thousands of children does not dump every
row into the picker; the response carries a truncated flag and the modal tells
the user to type a path to jump in.
* chore: untrack local venv symlink (run-only artifact)
* fix(workspace): vet the workspace root against the sensitive-path deny list at bind time
The in-workspace resolver deny-lists sensitive paths inside the workspace,
but the empty-path search root is the workspace itself, so a workspace of
~/.ssh could be listed via ls with no path. vet_workspace() (public, in
tool_execution next to the resolvers) rejects non-directories and sensitive
roots before the path is ever bound; chat_routes uses it instead of its
inline isdir check.
* fix(workspace): reject filesystem roots and stop showing rejected workspaces as active
Review findings from #3665:
P2: vet_workspace accepted / (and would accept drive/UNC roots), which makes
every absolute path 'inside' the workspace and collapses confinement into
host-wide file access. A root is its own dirname, so reject when
dirname(resolved) == resolved; the browse response now carries a selectable
flag and the picker disables 'Use this folder' on unselectable dirs.
P3: /workspace set stored any string client-side and the chat route silently
dropped rejected values, so the pill could claim a confinement that was not
in effect. New admin-gated /api/workspace/vet validates manual paths before
they persist (canonical path returned), and when a posted workspace is
rejected at send time the stream emits workspace_rejected so the client
clears the stored value and toasts instead of continuing silently.
* fix(workspace): check caller privilege before vetting the posted workspace
Review finding: /api/chat_stream called vet_workspace() on the posted value
for every caller and emitted workspace_rejected on failure, so a non-admin
who can chat but cannot use file/shell tools could distinguish existing
directories from missing/file/sensitive/root paths by whether the event
appeared. The resolution now lives in _resolve_request_workspace, which
drops the submitted value uniformly for non-admin callers, with no vetting
and no event, before the path ever touches the filesystem. Admin and
single-user behavior is unchanged. Test pins that valid and invalid paths
are indistinguishable for a non-admin and that vet_workspace is never
invoked for them.
2026-06-11 18:17:54 +02:00
|
|
|
WORKSPACE: 'odysseus-workspace'
|
2026-05-31 23:58:26 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|