| clear | pick]',
+ },
memory: {
alias: ['m'],
category: 'Memory',
diff --git a/static/js/storage.js b/static/js/storage.js
index c72a5db..7ff9c6b 100644
--- a/static/js/storage.js
+++ b/static/js/storage.js
@@ -23,7 +23,8 @@ export const KEYS = {
MCP_ACTIVE: 'odysseus-mcp-active',
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
- DENSITY: 'odysseus-density'
+ DENSITY: 'odysseus-density',
+ WORKSPACE: 'odysseus-workspace'
};
/**
diff --git a/static/js/workspace.js b/static/js/workspace.js
new file mode 100644
index 0000000..0e22eeb
--- /dev/null
+++ b/static/js/workspace.js
@@ -0,0 +1,160 @@
+// static/js/workspace.js
+//
+// Workspace picker: browse server directories in a draggable modal, choose a
+// folder, and show it as a removable pill in the chat input bar. While set, the
+// chat request sends `workspace` so the agent's file/shell tools are confined
+// to that folder (see routes/chat_routes.py + src/tool_execution.py).
+
+import Storage, { KEYS } from './storage.js';
+import uiModule from './ui.js';
+import { makeWindowDraggable } from './windowDrag.js';
+
+const API_BASE = window.location.origin;
+// Same folder glyph as the overflow menu item + pill (not an emoji).
+const _FOLDER_SVG = '';
+let _modal = null;
+let _curPath = '';
+
+export function getWorkspace() {
+ return Storage.get(KEYS.WORKSPACE, '') || '';
+}
+
+function _basename(p) {
+ if (!p) return '';
+ // Handle both POSIX (/) and Windows (\) separators.
+ const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
+ return parts[parts.length - 1] || p;
+}
+
+export function syncWorkspaceIndicator(path) {
+ const pill = document.getElementById('workspace-indicator-btn');
+ const name = document.getElementById('workspace-indicator-name');
+ const overflow = document.getElementById('overflow-workspace-btn');
+ if (pill) {
+ pill.style.display = path ? '' : 'none';
+ pill.classList.toggle('active', !!path);
+ if (path) pill.title = `Workspace: ${path} — click to clear`;
+ }
+ if (name) name.textContent = path ? _basename(path) : '';
+ if (overflow) overflow.classList.toggle('active', !!path);
+ // Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
+ try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
+}
+
+export function setWorkspace(path) {
+ if (path) Storage.set(KEYS.WORKSPACE, path);
+ else Storage.remove(KEYS.WORKSPACE);
+ syncWorkspaceIndicator(path || '');
+}
+
+export function clearWorkspace() {
+ setWorkspace('');
+ if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
+}
+
+async function _load(path) {
+ const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
+ const res = await fetch(url, { credentials: 'same-origin' });
+ if (!res.ok) throw new Error(`browse failed: ${res.status}`);
+ return res.json();
+}
+
+function _render(data) {
+ _curPath = data.path;
+ const body = _modal.querySelector('#workspace-body');
+ const pathEl = _modal.querySelector('#workspace-cur-path');
+ if (pathEl) {
+ // Reflect the resolved (realpath) location back into the editable field.
+ pathEl.value = data.path;
+ pathEl.title = data.path;
+ }
+ let rows = '';
+ if (data.parent) {
+ rows += `↑ ..
`;
+ }
+ for (const d of data.dirs) {
+ // Backend supplies the full child path (os.path.join → cross-platform).
+ rows += `${_FOLDER_SVG}${uiModule.esc(d.name)}
`;
+ }
+ if (!data.dirs.length && !data.parent) rows = 'No subfolders
';
+ body.innerHTML = rows || 'No subfolders
';
+ body.querySelectorAll('.workspace-row').forEach((row) => {
+ row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
+ });
+}
+
+async function _navigate(path) {
+ try {
+ _render(await _load(path));
+ } catch (e) {
+ if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
+ }
+}
+
+function _getModal() {
+ if (_modal) return _modal;
+ _modal = document.createElement('div');
+ _modal.id = 'workspace-modal';
+ _modal.className = 'modal';
+ _modal.style.display = 'none';
+ _modal.innerHTML = `
+ `;
+ document.body.appendChild(_modal);
+ _modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
+ _modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
+ // Editable path bar: Enter navigates to a typed/pasted folder.
+ _modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const v = e.target.value.trim();
+ if (v) _navigate(v);
+ }
+ });
+ _modal.querySelector('#workspace-use').addEventListener('click', () => {
+ setWorkspace(_curPath);
+ if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
+ closeWorkspaceBrowser();
+ });
+ const content = _modal.querySelector('.modal-content');
+ const header = _modal.querySelector('.modal-header');
+ if (content && header) makeWindowDraggable(_modal, { content, header });
+ return _modal;
+}
+
+export async function openWorkspaceBrowser() {
+ const modal = _getModal();
+ modal.style.display = 'flex';
+ try {
+ _render(await _load(getWorkspace() || ''));
+ } catch (e) {
+ if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
+ }
+}
+
+export function closeWorkspaceBrowser() {
+ if (_modal) _modal.style.display = 'none';
+}
+
+export function initWorkspace() {
+ // Restore persisted workspace into the pill on load.
+ syncWorkspaceIndicator(getWorkspace());
+ const overflow = document.getElementById('overflow-workspace-btn');
+ if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
+ const pill = document.getElementById('workspace-indicator-btn');
+ if (pill) pill.addEventListener('click', clearWorkspace);
+}
+
+export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };
diff --git a/static/style.css b/static/style.css
index 1710504..39f1e9e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -35877,3 +35877,46 @@ body.theme-frosted .modal {
line-height: 1.4;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
+/* ── Workspace picker ───────────────────────────────────────────── */
+/* Layout (width/flex column/max-height) inherited from base .modal-content. */
+/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
+ focus ring (set in the element's class list). Overrides only the deltas:
+ mono font, and full-bleed via flex stretch with no horizontal margin (the
+ modal-content's 10px padding is the gutter) instead of the base width:100%,
+ which overflowed against the overflow:auto scrollbar. */
+.workspace-cur {
+ align-self: stretch;
+ width: auto;
+ min-width: 0;
+ margin: 4px 0 8px;
+ font-family: var(--mono, monospace);
+ font-size: 12px;
+}
+/* flex/overflow inherited from base .modal-body; only the padding differs. */
+.workspace-body { padding: 6px 0; }
+.workspace-row {
+ padding: 7px 18px;
+ cursor: pointer;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.workspace-row > span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
+.workspace-row:hover {
+ background: color-mix(in srgb, var(--border) 20%, transparent);
+}
+.workspace-up { opacity: 0.7; }
+.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
+.workspace-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 10px 18px;
+ border-top: 1px solid var(--border);
+}
diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py
new file mode 100644
index 0000000..94ab327
--- /dev/null
+++ b/tests/test_workspace_confine.py
@@ -0,0 +1,128 @@
+"""Workspace confinement: file tools are hard-bounded to the workspace folder
+(layered on upstream's sensitive-path policy); bash runs with cwd there."""
+import os
+import tempfile
+
+import pytest
+
+from src.tool_execution import _resolve_tool_path_in_workspace, _direct_fallback
+
+
+def test_workspace_resolver_confines():
+ ws = tempfile.mkdtemp()
+ open(os.path.join(ws, "a.txt"), "w").write("x")
+ real = os.path.realpath(os.path.join(ws, "a.txt"))
+ # relative path resolves under the workspace
+ assert _resolve_tool_path_in_workspace(ws, "a.txt") == real
+ # absolute path inside the workspace is allowed
+ assert _resolve_tool_path_in_workspace(ws, os.path.join(ws, "a.txt")) == real
+ # absolute path outside is rejected (sibling temp dir, portable across OSes)
+ outside = tempfile.mkdtemp()
+ with pytest.raises(ValueError):
+ _resolve_tool_path_in_workspace(ws, os.path.join(outside, "x.txt"))
+ # parent-escape is rejected
+ with pytest.raises(ValueError):
+ _resolve_tool_path_in_workspace(ws, os.path.join("..", "..", "escape.txt"))
+
+
+def test_workspace_resolver_blocks_sensitive():
+ """Upstream's sensitive-file deny list still applies inside the workspace."""
+ ws = tempfile.mkdtemp()
+ os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
+ with pytest.raises(ValueError):
+ _resolve_tool_path_in_workspace(ws, ".ssh/authorized_keys")
+
+
+@pytest.mark.asyncio
+async def test_read_write_confined_in_workspace():
+ ws = tempfile.mkdtemp()
+ # Write inside the workspace (relative path) succeeds.
+ res = await _direct_fallback("write_file", "note.txt\nhello", workspace=ws)
+ assert res["exit_code"] == 0
+ assert os.path.isfile(os.path.join(ws, "note.txt"))
+ # Read it back.
+ res = await _direct_fallback("read_file", "note.txt", workspace=ws)
+ assert res["exit_code"] == 0 and res["output"] == "hello"
+ # Reading outside the workspace is rejected (sibling temp dir, portable).
+ outside = tempfile.mkdtemp()
+ outside_file = os.path.join(outside, "secret.txt")
+ open(outside_file, "w").write("nope")
+ res = await _direct_fallback("read_file", outside_file, workspace=ws)
+ assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
+ # Writing outside is rejected (file must not be created).
+ escape = os.path.join(outside, "_ws_escape.txt")
+ res = await _direct_fallback("write_file", f"{escape}\nx", workspace=ws)
+ assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
+ assert not os.path.exists(escape)
+
+
+def test_browse_is_admin_gated(monkeypatch):
+ """The directory-browser endpoint must refuse non-admin callers."""
+ from fastapi import HTTPException
+ import routes.workspace_routes as wr
+
+ router = wr.setup_workspace_routes()
+ browse = next(r.endpoint for r in router.routes if r.path == "/api/workspace/browse")
+
+ monkeypatch.setattr(wr, "get_current_user", lambda req: "bob")
+ monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: False)
+ with pytest.raises(HTTPException) as ei:
+ browse(request=object(), path="/")
+ assert ei.value.status_code == 403
+
+ # Admin / single-user is allowed.
+ monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
+ out = browse(request=object(), path=os.path.expanduser("~"))
+ assert "dirs" in out and "path" in out
+ assert all("name" in d and "path" in d for d in out["dirs"])
+
+
+@pytest.mark.asyncio
+async def test_subprocess_runs_with_workspace_cwd():
+ """bash/python subprocesses run with cwd set to the workspace. Use the
+ python tool for an OS-agnostic cwd probe (Windows cmd has no `pwd`)."""
+ ws = tempfile.mkdtemp()
+ res = await _direct_fallback("python", "import os; print(os.getcwd())", workspace=ws)
+ assert res["exit_code"] == 0
+ assert os.path.realpath(res["output"].strip()) == os.path.realpath(ws)
+
+
+# --- Tools that landed after this PR, now wired into the workspace -----------
+
+@pytest.mark.asyncio
+async def test_edit_file_confined_in_workspace():
+ import json
+ from src.tool_execution import _do_edit_file
+ ws = tempfile.mkdtemp()
+ open(os.path.join(ws, "f.txt"), "w").write("foo bar")
+ # Edit inside the workspace succeeds.
+ res = await _do_edit_file(json.dumps(
+ {"path": "f.txt", "old_string": "foo", "new_string": "baz"}), workspace=ws)
+ assert res["exit_code"] == 0
+ assert open(os.path.join(ws, "f.txt")).read() == "baz bar"
+ # Editing outside the workspace is rejected (sibling temp dir, portable).
+ outside = tempfile.mkdtemp()
+ outside_file = os.path.join(outside, "f.txt")
+ open(outside_file, "w").write("a")
+ res = await _do_edit_file(json.dumps(
+ {"path": outside_file, "old_string": "a", "new_string": "b"}), workspace=ws)
+ assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
+
+
+@pytest.mark.asyncio
+async def test_grep_and_ls_confined_in_workspace():
+ import json
+ ws = tempfile.mkdtemp()
+ open(os.path.join(ws, "doc.txt"), "w").write("hello workspace\n")
+ # grep with no path searches the workspace root and finds the match.
+ res = await _direct_fallback("grep", json.dumps({"pattern": "hello"}), workspace=ws)
+ assert res["exit_code"] == 0 and "doc.txt" in res["output"]
+ # grep pointed outside the workspace is rejected (sibling temp dir, portable).
+ outside = tempfile.mkdtemp()
+ res = await _direct_fallback("grep", json.dumps({"pattern": "x", "path": outside}), workspace=ws)
+ assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
+ # ls of the workspace lists its files; ls outside is rejected.
+ res = await _direct_fallback("ls", "", workspace=ws)
+ assert res["exit_code"] == 0 and "doc.txt" in res["output"]
+ res = await _direct_fallback("ls", outside, workspace=ws)
+ assert res["exit_code"] == 1 and "outside the workspace" in res["error"]