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.
This commit is contained in:
committed by
GitHub
parent
7b4365fe57
commit
2be3779e6e
@@ -4,6 +4,7 @@
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
@@ -1687,6 +1688,7 @@ function initializeEventListeners() {
|
||||
}
|
||||
setupToggle('web-toggle-btn', 'web-toggle', 'web');
|
||||
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
|
||||
@@ -1031,6 +1031,13 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1062,6 +1069,12 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<!-- RAG toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -2268,7 +2281,7 @@
|
||||
<script type="module" src="/static/js/chatRenderer.js"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260604q"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/compare/index.js"></script>
|
||||
|
||||
@@ -781,6 +781,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
if (_ws) {
|
||||
fd.append('workspace', _ws);
|
||||
}
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
@@ -1141,6 +1142,35 @@ async function _cmdToggleDoc(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean —
|
||||
// show / set <path> / clear / pick (open the directory browser).
|
||||
async function _cmdWorkspace(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
const rest = args.slice(1).join(' ').trim();
|
||||
const cur = workspaceModule.getWorkspace();
|
||||
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
|
||||
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'set' || sub === 'cd' || sub === 'use') {
|
||||
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
|
||||
workspaceModule.setWorkspace(rest);
|
||||
slashReply(`Workspace set: <code>${uiModule.esc(rest)}</code>`);
|
||||
return true;
|
||||
}
|
||||
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
|
||||
workspaceModule.clearWorkspace();
|
||||
slashReply('Workspace cleared.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
|
||||
workspaceModule.openWorkspaceBrowser();
|
||||
return true;
|
||||
}
|
||||
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdToggleShow(args, ctx) {
|
||||
const name = (args[0] || '').toLowerCase();
|
||||
const val = (args[1] || '').toLowerCase();
|
||||
@@ -5455,6 +5485,14 @@ const COMMANDS = {
|
||||
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
alias: ['ws'],
|
||||
category: 'Agent',
|
||||
help: 'Set the folder the agent works in',
|
||||
handler: _cmdWorkspace,
|
||||
noUserBubble: true,
|
||||
usage: '/workspace [set <path> | clear | pick]',
|
||||
},
|
||||
memory: {
|
||||
alias: ['m'],
|
||||
category: 'Memory',
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
160
static/js/workspace.js
Normal file
160
static/js/workspace.js
Normal file
@@ -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 = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></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 += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
|
||||
}
|
||||
for (const d of data.dirs) {
|
||||
// Backend supplies the full child path (os.path.join → cross-platform).
|
||||
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
|
||||
}
|
||||
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
|
||||
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
|
||||
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 = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
|
||||
<button class="close-btn" id="workspace-close" aria-label="Close">✖</button>
|
||||
</div>
|
||||
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||
placeholder="Type or paste a folder path, then press Enter" />
|
||||
<div class="modal-body workspace-body" id="workspace-body"></div>
|
||||
<div class="modal-footer workspace-footer">
|
||||
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
|
||||
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user