mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
620fdd0859
* 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.
209 lines
8.9 KiB
JavaScript
209 lines
8.9 KiB
JavaScript
// 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;
|
|
}
|
|
|
|
// Workspace only applies to agent mode (it scopes the file/shell tools), so the
|
|
// pill + overflow entry are hidden in chat mode, like the bash toggle.
|
|
function _isChatMode() {
|
|
const b = document.getElementById('mode-chat-btn');
|
|
return !!(b && b.classList.contains('active'));
|
|
}
|
|
|
|
export function syncWorkspaceIndicator(path) {
|
|
const chat = _isChatMode();
|
|
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 && !chat) ? '' : 'none';
|
|
pill.classList.toggle('active', !!path);
|
|
if (path) pill.title = `Workspace: ${path}\nFile tools are confined here; shell commands start here but are not sandboxed and can reach outside it.\nClick to clear.`;
|
|
}
|
|
if (name) name.textContent = path ? _basename(path) : '';
|
|
if (overflow) {
|
|
overflow.style.display = chat ? 'none' : '';
|
|
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 (_) {}
|
|
}
|
|
|
|
// Called by the agent/chat mode toggle so the pill + overflow entry follow mode.
|
|
export function applyMode(_mode) {
|
|
syncWorkspaceIndicator(getWorkspace());
|
|
}
|
|
|
|
export function setWorkspace(path) {
|
|
if (path) Storage.set(KEYS.WORKSPACE, path);
|
|
else Storage.remove(KEYS.WORKSPACE);
|
|
syncWorkspaceIndicator(path || '');
|
|
}
|
|
|
|
/**
|
|
* Validate a manually entered path server-side, then persist the canonical
|
|
* form. Returns {ok, path|null}. Without this, a typo / file path / deleted
|
|
* folder / filesystem root would be stored and shown as active while the
|
|
* backend silently refuses to bind it on every send.
|
|
*/
|
|
export async function vetAndSetWorkspace(path) {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/workspace/vet?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' });
|
|
if (!res.ok) return { ok: false, path: null };
|
|
const data = await res.json();
|
|
if (data.ok && data.path) {
|
|
setWorkspace(data.path);
|
|
return { ok: true, path: data.path };
|
|
}
|
|
return { ok: false, path: null };
|
|
} catch (e) {
|
|
return { ok: false, path: null };
|
|
}
|
|
}
|
|
|
|
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.truncated) {
|
|
rows += '<div class="workspace-empty">Too many folders to list. Type or paste a path above to jump in.</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)));
|
|
});
|
|
// Filesystem roots (and sensitive dirs) can be browsed through but never
|
|
// bound as the workspace; the backend rejects them too.
|
|
const useBtn = _modal.querySelector('#workspace-use');
|
|
if (useBtn) {
|
|
useBtn.disabled = data.selectable === false;
|
|
useBtn.title = data.selectable === false ? 'This folder cannot be used as a workspace' : '';
|
|
}
|
|
}
|
|
|
|
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" />
|
|
<p class="muted workspace-note">File tools are <strong>confined</strong> to this folder. Shell commands start here but are <strong>not sandboxed</strong> and can reach outside it. A workspace scopes the tools; it is not a security boundary.</p>
|
|
<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, vetAndSetWorkspace, clearWorkspace, syncWorkspaceIndicator, applyMode };
|