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:
Rohit C Prasad
2026-07-30 13:10:30 -07:00
parent 11d9f72e51
commit 25dc283d9b
10 changed files with 236 additions and 37 deletions
+5
View File
@@ -1367,6 +1367,11 @@ def create_app(manager: SessionManager) -> FastAPI:
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03). # Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5)) 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") @app.post("/v1/settings/pdf")
def settings_set_pdf(body: dict) -> dict[str, Any]: def settings_set_pdf(body: dict) -> dict[str, Any]:
# Token savings (owner ask, 2026-07-17): fallback mode for models without native # Token savings (owner ask, 2026-07-17): fallback mode for models without native
+43 -24
View File
@@ -1250,32 +1250,40 @@ class SessionManager:
".doc", ".doc",
".docm", ".docm",
} }
for path in root.rglob("*"): # os.walk with in-place pruning, NOT rglob: rglob descends first and filters after,
try: # so a home-directory workspace walked into ~/Library and tripped the macOS App Data
rel = path.relative_to(root) # TCC prompt ("OpenWorker would like to access data from other apps") on every turn.
if any( # Pruning here means those directories are never entered at all.
part.startswith(".") from ..tools.search import OS_DATA_DIRS
or part in {"node_modules", "target", "dist", "__pycache__"}
for part in rel.parts 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 continue
if not path.is_file() or path.suffix.lower() not in suffixes: path = Path(dirpath) / name
if path.suffix.lower() not in suffixes:
continue
try:
st = path.stat()
if not path.is_file():
continue
out.append(
{
"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),
"size": st.st_size,
"modified_at": st.st_mtime,
}
)
except OSError:
continue continue
st = path.stat()
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).
"abs_path": str(path),
"name": path.name,
"kind": _artifact_kind(path),
"size": st.st_size,
"modified_at": st.st_mtime,
}
)
except OSError:
continue
out.sort(key=lambda a: a["modified_at"], reverse=True) out.sort(key=lambda a: a["modified_at"], reverse=True)
return out[:80] return out[:80]
@@ -1805,6 +1813,7 @@ class SessionManager:
"surfaces": self._surfaces(), "surfaces": self._surfaces(),
"nav_layout": self._nav_layout(), "nav_layout": self._nav_layout(),
"sessions_peek": self.sessions_peek(), "sessions_peek": self.sessions_peek(),
"context_bar": self.context_bar(),
"scratch_base": self._prefs.get("scratch_base") "scratch_base": self._prefs.get("scratch_base")
or self.DEFAULT_SCRATCH_BASE, or self.DEFAULT_SCRATCH_BASE,
# Real on-disk secrets location, so the UI shows the OS-native path instead of a # 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() self._save_prefs()
return {"ok": True, "sessions_peek": self.sessions_peek()} 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) ---------------- # -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
DEFAULT_PDF_MAX_PAGES = 20 DEFAULT_PDF_MAX_PAGES = 20
DEFAULT_PDF_MAX_MB = 10 DEFAULT_PDF_MAX_MB = 10
+13 -1
View File
@@ -16,6 +16,18 @@ from typing import Any, Optional
import aisuite as ai 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 = { _IGNORE_DIRS = {
".git", ".git",
"node_modules", "node_modules",
@@ -30,7 +42,7 @@ _IGNORE_DIRS = {
".pytest_cache", ".pytest_cache",
".ruff_cache", ".ruff_cache",
".idea", ".idea",
} } | OS_DATA_DIRS
_SCHEMA = { _SCHEMA = {
"type": "function", "type": "function",
+4
View File
@@ -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/health")) return json(HEALTH);
if (p.endsWith("/v1/settings")) return json(SETTINGS); 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") { if (p.endsWith("/v1/settings/pdf") && m === "POST") {
Object.assign(SETTINGS, req.postDataJSON()); Object.assign(SETTINGS, req.postDataJSON());
return json({ return json({
+35 -2
View File
@@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({
timeout: 10_000, 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"); const chip = page.getByTestId("usage-chip");
await expect(chip).toContainText("10k"); 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/); const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello"); await box.fill("hello");
await box.press("Enter"); 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. // " New session" wipes the transcript — and the usage accumulation with it.
await page.getByRole("button", { name: /New session/ }).first().click(); await page.getByRole("button", { name: /New session/ }).first().click();
await expect(page.getByTestId("usage-chip")).toHaveCount(0); 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/);
});
+5
View File
@@ -165,6 +165,9 @@ export function App() {
// {full model id → context window in tokens} from the curated matrix (verified only); // {full model id → context window in tokens} from the curated matrix (verified only);
// drives the composer usage chip's context-fill meter. // drives the composer usage chip's context-fill meter.
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({}); 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, // Per-session token usage (OPE-42): rebuilt from the transcript on session load,
// accumulated live from assistant_message events, reset with the transcript. // accumulated live from assistant_message events, reset with the transcript.
const [usage, setUsage] = useState<SessionUsage>(emptyUsage()); const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
@@ -506,6 +509,7 @@ export function App() {
setModels(s.models || []); setModels(s.models || []);
setModelLabels(s.model_labels || {}); setModelLabels(s.model_labels || {});
setModelContextWindows(s.model_context_windows || {}); setModelContextWindows(s.model_context_windows || {});
setContextBar(s.context_bar === true);
setModelReady(s.model_ready); setModelReady(s.model_ready);
if (s.surfaces) setSurfaces(s.surfaces); if (s.surfaces) setSurfaces(s.surfaces);
}) })
@@ -1587,6 +1591,7 @@ export function App() {
resetKey={sessionId} resetKey={sessionId}
usage={usage} usage={usage}
contextWindow={modelContextWindows[model]} contextWindow={modelContextWindows[model]}
contextBar={contextBar}
placeholder={ placeholder={
agent === "code" agent === "code"
? "Ask the coder to build, fix, or explain… (drop or paste files)" ? "Ask the coder to build, fix, or explain… (drop or paste files)"
+15
View File
@@ -692,6 +692,9 @@ export interface ModelSettings {
nav_layout?: "flat" | "grouped"; nav_layout?: "flat" | "grouped";
// Sidebar: sessions shown per group before "Show more" (default 5, 150). // Sidebar: sessions shown per group before "Show more" (default 5, 150).
sessions_peek?: number; 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. // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
model_labels?: Record<string, string>; model_labels?: Record<string, string>;
// {full id → context window in tokens}, verified matrix entries only — drives the // {full id → context window in tokens}, verified matrix entries only — drives the
@@ -758,6 +761,18 @@ export async function inspectPdf(
return res.json(); 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". */ /** Persist how many sessions a sidebar group shows before "Show more". */
export async function setSessionsPeek( export async function setSessionsPeek(
n: number, n: number,
+21 -10
View File
@@ -84,6 +84,8 @@ interface Props {
// Context-window size (tokens) of the ACTIVE model, from the curated matrix; // Context-window size (tokens) of the ACTIVE model, from the curated matrix;
// undefined hides the fill meter (unverified/custom models) but keeps the counts. // undefined hides the fill meter (unverified/custom models) but keeps the counts.
contextWindow?: number; contextWindow?: number;
// Settings toggle (default off): true shows the fill bar instead of the session total.
contextBar?: boolean;
} }
export function Composer(props: Props) { export function Composer(props: Props) {
@@ -469,13 +471,14 @@ export function Composer(props: Props) {
<span className="ml-auto" /> <span className="ml-auto" />
{/* token usage (OPE-42) a quiet meter+count chip; hidden until the server {/* token usage (OPE-42) a quiet chip; hidden until the server reports usage.
reports usage. Fill = context-window occupancy (bounded), count = session Shows the context-window fill bar alone (the session total lives in the
consumption (unbounded, so never a fill). */} popover), or the session total when there's no window / the bar is off. */}
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && ( {!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
<UsageChip <UsageChip
usage={props.usage} usage={props.usage}
contextWindow={props.contextWindow} contextWindow={props.contextWindow}
contextBar={props.contextBar}
model={props.model} model={props.model}
modelLabels={props.modelLabels} modelLabels={props.modelLabels}
/> />
@@ -572,11 +575,13 @@ export function Composer(props: Props) {
function UsageChip({ function UsageChip({
usage, usage,
contextWindow, contextWindow,
contextBar,
model, model,
modelLabels, modelLabels,
}: { }: {
usage: SessionUsage; usage: SessionUsage;
contextWindow?: number; contextWindow?: number;
contextBar?: boolean;
model: string; model: string;
modelLabels?: Record<string, string>; modelLabels?: Record<string, string>;
}) { }) {
@@ -585,6 +590,8 @@ function UsageChip({
const pct = contextWindow const pct = contextWindow
? Math.min(100, Math.round((usage.context / contextWindow) * 100)) ? Math.min(100, Math.round((usage.context / contextWindow) * 100))
: null; : 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) => const labelFor = (id: string) =>
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id); id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative // One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
@@ -605,21 +612,25 @@ function UsageChip({
aria-expanded={open} aria-expanded={open}
aria-label="Token usage" aria-label="Token usage"
title={ title={
pct !== null showBar
? `Token usage — ${pct}% of the context window used` ? `Context window ${pct}% full · ${formatTokens(total)} tokens this session`
: "Token usage this session" : `Token usage this session: ${formatTokens(total)}`
} }
data-testid="usage-chip" data-testid="usage-chip"
> >
{pct !== null && ( {/* The bar is the context-window fill; pairing it with the session TOTAL read as
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true"> "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 <span
className="block h-full bg-accent transition-all" className="block h-full bg-accent transition-all"
style={{ width: `${Math.max(pct, 4)}%` }} style={{ width: `${Math.max(pct as number, 4)}%` }}
/> />
</span> </span>
) : (
<span className="tabular-nums">{formatTokens(total)}</span>
)} )}
<span className="tabular-nums">{formatTokens(total)}</span>
</button> </button>
{open && ( {open && (
<> <>
@@ -3,6 +3,7 @@ import {
getSettings, getSettings,
getTrustedWorkspaces, getTrustedWorkspaces,
setCompactionSettings, setCompactionSettings,
setContextBar,
setOnboarded, setOnboarded,
setPdfSettings, setPdfSettings,
setScratchBase, setScratchBase,
@@ -428,6 +429,8 @@ function AppearanceSection() {
<SidebarCard /> <SidebarCard />
<ContextBarCard />
<FilesCard /> <FilesCard />
<TrustedWorkspacesCard /> <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&rsquo;s context window is. Turn it off
to show this session&rsquo;s token total instead; either way the full breakdown
is one click away.
</span>
</span>
</label>
</div>
);
}
function SidebarCard() { function SidebarCard() {
const [peek, setPeek] = useState<number | null>(null); const [peek, setPeek] = useState<number | null>(null);
+50
View File
@@ -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