mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 15:50:02 +00:00
fix: stop artifact walk entering OS app-data dirs; context bar off by default
The artifacts scan used rglob and filtered after descending, so a home directory workspace walked into ~/Library and triggered the macOS App Data consent prompt on every turn. Walk with pruning instead, and skip Library / AppData in search too. The composer chip now shows the session total by default, with the context window bar behind a Settings toggle.
This commit is contained in:
@@ -1367,6 +1367,11 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
|
||||
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5))
|
||||
|
||||
@app.post("/v1/settings/context-bar")
|
||||
def settings_set_context_bar(body: dict) -> dict[str, Any]:
|
||||
# Composer: show the context-window fill bar, or just the popover (owner ask).
|
||||
return manager.set_context_bar((body or {}).get("context_bar", True))
|
||||
|
||||
@app.post("/v1/settings/pdf")
|
||||
def settings_set_pdf(body: dict) -> dict[str, Any]:
|
||||
# Token savings (owner ask, 2026-07-17): fallback mode for models without native
|
||||
|
||||
+32
-13
@@ -1250,23 +1250,31 @@ class SessionManager:
|
||||
".doc",
|
||||
".docm",
|
||||
}
|
||||
for path in root.rglob("*"):
|
||||
# os.walk with in-place pruning, NOT rglob: rglob descends first and filters after,
|
||||
# so a home-directory workspace walked into ~/Library and tripped the macOS App Data
|
||||
# TCC prompt ("OpenWorker would like to access data from other apps") on every turn.
|
||||
# Pruning here means those directories are never entered at all.
|
||||
from ..tools.search import OS_DATA_DIRS
|
||||
|
||||
skip = {"node_modules", "target", "dist", "__pycache__"} | OS_DATA_DIRS
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip]
|
||||
for name in files:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
path = Path(dirpath) / name
|
||||
if path.suffix.lower() not in suffixes:
|
||||
continue
|
||||
try:
|
||||
rel = path.relative_to(root)
|
||||
if any(
|
||||
part.startswith(".")
|
||||
or part in {"node_modules", "target", "dist", "__pycache__"}
|
||||
for part in rel.parts
|
||||
):
|
||||
continue
|
||||
if not path.is_file() or path.suffix.lower() not in suffixes:
|
||||
continue
|
||||
st = path.stat()
|
||||
if not path.is_file():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"path": str(rel),
|
||||
# Absolute path for "Copy path" — the relative one is useless outside
|
||||
# the app (tester catch 2026-07-12: it copied just the filename).
|
||||
"path": str(path.relative_to(root)),
|
||||
# Absolute path for "Copy path" — the relative one is useless
|
||||
# outside the app (tester catch 2026-07-12: it copied just the
|
||||
# filename).
|
||||
"abs_path": str(path),
|
||||
"name": path.name,
|
||||
"kind": _artifact_kind(path),
|
||||
@@ -1805,6 +1813,7 @@ class SessionManager:
|
||||
"surfaces": self._surfaces(),
|
||||
"nav_layout": self._nav_layout(),
|
||||
"sessions_peek": self.sessions_peek(),
|
||||
"context_bar": self.context_bar(),
|
||||
"scratch_base": self._prefs.get("scratch_base")
|
||||
or self.DEFAULT_SCRATCH_BASE,
|
||||
# Real on-disk secrets location, so the UI shows the OS-native path instead of a
|
||||
@@ -1864,6 +1873,16 @@ class SessionManager:
|
||||
self._save_prefs()
|
||||
return {"ok": True, "sessions_peek": self.sessions_peek()}
|
||||
|
||||
def context_bar(self) -> bool:
|
||||
"""Whether the composer shows the context-window fill bar. OFF by default (owner
|
||||
ask): the chip then states the session total, and the popover keeps both numbers."""
|
||||
return bool(self._prefs.get("context_bar", False))
|
||||
|
||||
def set_context_bar(self, shown: Any) -> dict[str, Any]:
|
||||
self._prefs["context_bar"] = bool(shown)
|
||||
self._save_prefs()
|
||||
return {"ok": True, "context_bar": self.context_bar()}
|
||||
|
||||
# -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
|
||||
DEFAULT_PDF_MAX_PAGES = 20
|
||||
DEFAULT_PDF_MAX_MB = 10
|
||||
|
||||
@@ -16,6 +16,18 @@ from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
# Per-OS application data directories. These are not build noise: on macOS 14+ merely
|
||||
# *descending* into ~/Library/Application Support (other apps' containers) trips the App
|
||||
# Data TCC protection and macOS shows "would like to access data from other apps" — an
|
||||
# alarming prompt the user never asked for, reachable whenever the workspace is a home
|
||||
# directory. Never traversed; a workspace under one of these is still searched normally,
|
||||
# because the guard matches directory NAMES encountered during a walk.
|
||||
OS_DATA_DIRS = {
|
||||
"Library", # macOS
|
||||
"AppData", # Windows
|
||||
"Application Data", # Windows (legacy junction)
|
||||
}
|
||||
|
||||
_IGNORE_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
@@ -30,7 +42,7 @@ _IGNORE_DIRS = {
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".idea",
|
||||
}
|
||||
} | OS_DATA_DIRS
|
||||
|
||||
_SCHEMA = {
|
||||
"type": "function",
|
||||
|
||||
@@ -838,6 +838,10 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
|
||||
if (p.endsWith("/v1/health")) return json(HEALTH);
|
||||
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
||||
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
|
||||
Object.assign(SETTINGS, req.postDataJSON());
|
||||
return json({ ok: true, context_bar: SETTINGS.context_bar });
|
||||
}
|
||||
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
|
||||
Object.assign(SETTINGS, req.postDataJSON());
|
||||
return json({
|
||||
|
||||
@@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Chip shows the session total (1k + 200 + 8k + 800 = 10k).
|
||||
// Default: no bar (owner ask 2026-07-30) — the chip states the session total
|
||||
// (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings.
|
||||
const chip = page.getByTestId("usage-chip");
|
||||
await expect(chip).toContainText("10k");
|
||||
|
||||
@@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => {
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello");
|
||||
await box.press("Enter");
|
||||
await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 });
|
||||
await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// "+ New session" wipes the transcript — and the usage accumulation with it.
|
||||
await page.getByRole("button", { name: /New session/ }).first().click();
|
||||
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello");
|
||||
await box.press("Enter");
|
||||
const chip = page.getByTestId("usage-chip");
|
||||
await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar
|
||||
|
||||
// Turn the bar ON in Settings -> General.
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked();
|
||||
const [req] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST",
|
||||
),
|
||||
page.getByTestId("context-bar-toggle").check(),
|
||||
]);
|
||||
expect(req.postDataJSON()).toEqual({ context_bar: true });
|
||||
|
||||
// Reload so the app re-reads settings: the chip is now the fill bar, not a number.
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
|
||||
await page.getByPlaceholder(/Ask the coworker/).press("Enter");
|
||||
const bar = page.getByTestId("usage-chip");
|
||||
await expect(bar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(bar).not.toContainText("10k");
|
||||
await expect(bar).toHaveAttribute("title", /Context window 5% full/);
|
||||
});
|
||||
|
||||
@@ -165,6 +165,9 @@ export function App() {
|
||||
// {full model id → context window in tokens} from the curated matrix (verified only);
|
||||
// drives the composer usage chip's context-fill meter.
|
||||
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
|
||||
// Settings: show the composer's context-window fill bar. OFF by default (owner ask),
|
||||
// so an older backend without the field also shows the session total.
|
||||
const [contextBar, setContextBar] = useState(false);
|
||||
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
|
||||
// accumulated live from assistant_message events, reset with the transcript.
|
||||
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
|
||||
@@ -506,6 +509,7 @@ export function App() {
|
||||
setModels(s.models || []);
|
||||
setModelLabels(s.model_labels || {});
|
||||
setModelContextWindows(s.model_context_windows || {});
|
||||
setContextBar(s.context_bar === true);
|
||||
setModelReady(s.model_ready);
|
||||
if (s.surfaces) setSurfaces(s.surfaces);
|
||||
})
|
||||
@@ -1587,6 +1591,7 @@ export function App() {
|
||||
resetKey={sessionId}
|
||||
usage={usage}
|
||||
contextWindow={modelContextWindows[model]}
|
||||
contextBar={contextBar}
|
||||
placeholder={
|
||||
agent === "code"
|
||||
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
||||
|
||||
@@ -692,6 +692,9 @@ export interface ModelSettings {
|
||||
nav_layout?: "flat" | "grouped";
|
||||
// Sidebar: sessions shown per group before "Show more" (default 5, 1–50).
|
||||
sessions_peek?: number;
|
||||
// Composer: show the context-window fill bar (default FALSE; absent → the chip shows
|
||||
// the session total). The usage popover keeps both numbers regardless.
|
||||
context_bar?: boolean;
|
||||
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
||||
model_labels?: Record<string, string>;
|
||||
// {full id → context window in tokens}, verified matrix entries only — drives the
|
||||
@@ -758,6 +761,18 @@ export async function inspectPdf(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Persist whether the composer shows the context-window fill bar. */
|
||||
export async function setContextBar(
|
||||
shown: boolean,
|
||||
): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> {
|
||||
const res = await fetch(`${httpBase()}/v1/settings/context-bar`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ context_bar: shown }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Persist how many sessions a sidebar group shows before "Show more". */
|
||||
export async function setSessionsPeek(
|
||||
n: number,
|
||||
|
||||
@@ -84,6 +84,8 @@ interface Props {
|
||||
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
|
||||
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
|
||||
contextWindow?: number;
|
||||
// Settings toggle (default off): true shows the fill bar instead of the session total.
|
||||
contextBar?: boolean;
|
||||
}
|
||||
|
||||
export function Composer(props: Props) {
|
||||
@@ -469,13 +471,14 @@ export function Composer(props: Props) {
|
||||
|
||||
<span className="ml-auto" />
|
||||
|
||||
{/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server
|
||||
reports usage. Fill = context-window occupancy (bounded), count = session
|
||||
consumption (unbounded, so never a fill). */}
|
||||
{/* token usage (OPE-42) — a quiet chip; hidden until the server reports usage.
|
||||
Shows the context-window fill bar alone (the session total lives in the
|
||||
popover), or the session total when there's no window / the bar is off. */}
|
||||
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
|
||||
<UsageChip
|
||||
usage={props.usage}
|
||||
contextWindow={props.contextWindow}
|
||||
contextBar={props.contextBar}
|
||||
model={props.model}
|
||||
modelLabels={props.modelLabels}
|
||||
/>
|
||||
@@ -572,11 +575,13 @@ export function Composer(props: Props) {
|
||||
function UsageChip({
|
||||
usage,
|
||||
contextWindow,
|
||||
contextBar,
|
||||
model,
|
||||
modelLabels,
|
||||
}: {
|
||||
usage: SessionUsage;
|
||||
contextWindow?: number;
|
||||
contextBar?: boolean;
|
||||
model: string;
|
||||
modelLabels?: Record<string, string>;
|
||||
}) {
|
||||
@@ -585,6 +590,8 @@ function UsageChip({
|
||||
const pct = contextWindow
|
||||
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
|
||||
: null;
|
||||
// Settings can hide the bar; without a known window there is nothing to fill either.
|
||||
const showBar = pct !== null && contextBar === true;
|
||||
const labelFor = (id: string) =>
|
||||
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
|
||||
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
|
||||
@@ -605,21 +612,25 @@ function UsageChip({
|
||||
aria-expanded={open}
|
||||
aria-label="Token usage"
|
||||
title={
|
||||
pct !== null
|
||||
? `Token usage — ${pct}% of the context window used`
|
||||
: "Token usage this session"
|
||||
showBar
|
||||
? `Context window ${pct}% full · ${formatTokens(total)} tokens this session`
|
||||
: `Token usage this session: ${formatTokens(total)}`
|
||||
}
|
||||
data-testid="usage-chip"
|
||||
>
|
||||
{pct !== null && (
|
||||
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||
{/* The bar is the context-window fill; pairing it with the session TOTAL read as
|
||||
"total is N% of the window", which it never was. Bar alone when we have a
|
||||
window, the session total only when we don't (so the chip is never empty). */}
|
||||
{showBar ? (
|
||||
<span className="w-12 h-1.5 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||
<span
|
||||
className="block h-full bg-accent transition-all"
|
||||
style={{ width: `${Math.max(pct, 4)}%` }}
|
||||
style={{ width: `${Math.max(pct as number, 4)}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
) : (
|
||||
<span className="tabular-nums">{formatTokens(total)}</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getSettings,
|
||||
getTrustedWorkspaces,
|
||||
setCompactionSettings,
|
||||
setContextBar,
|
||||
setOnboarded,
|
||||
setPdfSettings,
|
||||
setScratchBase,
|
||||
@@ -428,6 +429,8 @@ function AppearanceSection() {
|
||||
|
||||
<SidebarCard />
|
||||
|
||||
<ContextBarCard />
|
||||
|
||||
<FilesCard />
|
||||
|
||||
<TrustedWorkspacesCard />
|
||||
@@ -790,6 +793,48 @@ function CompactionCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Composer: context-window bar (owner ask 2026-07-30) ------------------------
|
||||
// The chip's bar is context-window occupancy; the session total (unbounded) lives in
|
||||
// the popover. Some people would rather not watch a meter at all, hence the toggle.
|
||||
function ContextBarCard() {
|
||||
const [shown, setShown] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then((s) => setShown(s.context_bar === true))
|
||||
.catch(() => setShown(false));
|
||||
}, []);
|
||||
|
||||
const save = async (next: boolean) => {
|
||||
setShown(next);
|
||||
await setContextBar(next);
|
||||
};
|
||||
|
||||
if (shown === null) return null;
|
||||
return (
|
||||
<div className={CARD + " p-4 mb-4"} data-testid="context-bar-card">
|
||||
<div className={FIELD_LABEL}>Composer</div>
|
||||
<label className="flex items-start gap-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
data-testid="context-bar-toggle"
|
||||
checked={shown}
|
||||
onChange={(e) => save(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-[13px] text-ink">Show the context window bar</span>
|
||||
<span className="block text-[12px] text-muted">
|
||||
A small meter showing how full the model’s context window is. Turn it off
|
||||
to show this session’s token total instead; either way the full breakdown
|
||||
is one click away.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCard() {
|
||||
const [peek, setPeek] = useState<number | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""list_artifacts must never descend into OS application-data directories.
|
||||
|
||||
On macOS 14+, merely traversing ~/Library/Application Support (other apps' containers)
|
||||
trips the App Data TCC protection and the user gets an alarming "OpenWorker would like to
|
||||
access data from other apps" prompt. The artifacts panel refreshes after every turn, so a
|
||||
home-directory workspace produced that prompt unprompted. Pruning must happen DURING the
|
||||
walk (rglob descends first and filters after, which is what caused the bug).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from coworker.server.manager import SessionManager
|
||||
from coworker.tools.search import OS_DATA_DIRS
|
||||
|
||||
|
||||
def _ws(tmp_path):
|
||||
ws = tmp_path / "home"
|
||||
(ws / "Library" / "Application Support" / "SomeOtherApp").mkdir(parents=True)
|
||||
(ws / "Library" / "Application Support" / "SomeOtherApp" / "secrets.json").write_text("{}")
|
||||
(ws / "Library" / "notes.md").write_text("# private")
|
||||
(ws / "node_modules" / "pkg").mkdir(parents=True)
|
||||
(ws / "node_modules" / "pkg" / "readme.md").write_text("# dep")
|
||||
(ws / "report.md").write_text("# real artifact")
|
||||
return ws
|
||||
|
||||
|
||||
def test_os_data_dirs_are_not_traversed(tmp_path, monkeypatch):
|
||||
ws = _ws(tmp_path)
|
||||
walked: list[str] = []
|
||||
real_walk = os.walk
|
||||
|
||||
def spy(top, *a, **k):
|
||||
for dirpath, dirs, files in real_walk(top, *a, **k):
|
||||
walked.append(dirpath)
|
||||
yield dirpath, dirs, files
|
||||
|
||||
monkeypatch.setattr("coworker.server.manager.os.walk", spy)
|
||||
m = SessionManager(data_dir=tmp_path / "data", workspace=str(ws))
|
||||
names = [a["name"] for a in m.list_artifacts("s1")]
|
||||
|
||||
assert "report.md" in names
|
||||
# The private file is skipped AND its directory was never entered (the TCC trigger).
|
||||
assert "notes.md" not in names
|
||||
assert "secrets.json" not in names
|
||||
assert not any("Library" in p for p in walked), f"descended into Library: {walked}"
|
||||
assert not any("node_modules" in p for p in walked)
|
||||
|
||||
|
||||
def test_os_data_dirs_cover_mac_and_windows():
|
||||
assert {"Library", "AppData", "Application Data"} <= OS_DATA_DIRS
|
||||
Reference in New Issue
Block a user