From efeaae1e19c6bc00033f346e2ad6f9d205124821 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:45:58 +0530 Subject: [PATCH] refactor(x): workspace-context loading gets one trait-gated chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspaceContext trait was consulted separately at every assembly site before loading agent notes / work dir — a convention each caller had to re-remember, where a forgotten check either leaks the user's agent-memory into non-copilot prompts or silently omits it for the copilot, and neither fails loudly. The loaders move out of the legacy runtime file into agents/workspace-context.ts, and loadWorkspaceContext() applies the trait gate INSIDE: both engines now call it (the resolver keeps its loader test-seams by injecting them through), so a future assembly site structurally cannot skip the gate. Tests pin the gate: non-workspace agents get nulls even when the loaders would yield values. Co-Authored-By: Claude Fable 5 --- apps/x/packages/core/src/agents/runtime.ts | 80 +----------- .../core/src/agents/workspace-context.test.ts | 41 +++++++ .../core/src/agents/workspace-context.ts | 115 ++++++++++++++++++ .../src/turns/bridges/real-agent-resolver.ts | 15 ++- 4 files changed, 169 insertions(+), 82 deletions(-) create mode 100644 apps/x/packages/core/src/agents/workspace-context.test.ts create mode 100644 apps/x/packages/core/src/agents/workspace-context.ts diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 53534fd0..d4d9b9c3 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -12,6 +12,7 @@ import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js"; import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js"; import { hasWorkspaceContext, loadAgent } from "./registry.js"; +import { loadWorkspaceContext } from "./workspace-context.js"; import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js"; import { resolveFilePathForPermission } from "../filesystem/files.js"; @@ -31,14 +32,6 @@ import { captureLlmUsage } from "../analytics/usage.js"; import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js"; import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js"; -const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); - -// Work directory is scoped per run (per chat). Each run gets its own sidecar -// config file so setting it in one chat does not leak into others. -function workDirConfigFile(runId: string): string { - return path.join(WorkDir, 'config', `workdir-${runId}.json`); -} - type ToolPermissionMetadataValue = z.infer; function isPathInside(parent: string, child: string): boolean { @@ -164,71 +157,6 @@ export async function getToolPermissionMetadata( }; } -export function loadUserWorkDir(runId: string): string | null { - try { - const file = workDirConfigFile(runId); - if (!fs.existsSync(file)) return null; - const raw = fs.readFileSync(file, 'utf-8'); - const parsed = JSON.parse(raw) as { path?: unknown }; - const value = typeof parsed.path === 'string' ? parsed.path.trim() : ''; - return value || null; - } catch { - return null; - } -} - -export function loadAgentNotesContext(): string | null { - const sections: string[] = []; - - const userFile = path.join(AGENT_NOTES_DIR, 'user.md'); - const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md'); - - try { - if (fs.existsSync(userFile)) { - const content = fs.readFileSync(userFile, 'utf-8').trim(); - if (content) { - sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`); - } - } - } catch { /* ignore */ } - - try { - if (fs.existsSync(prefsFile)) { - const content = fs.readFileSync(prefsFile, 'utf-8').trim(); - if (content) { - sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`); - } - } - } catch { /* ignore */ } - - // List other Agent Notes files for on-demand access - const otherFiles: string[] = []; - const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']); - try { - if (fs.existsSync(AGENT_NOTES_DIR)) { - function listMdFiles(dir: string, prefix: string) { - for (const entry of fs.readdirSync(dir)) { - const fullPath = path.join(dir, entry); - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - listMdFiles(fullPath, `${prefix}${entry}/`); - } else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) { - otherFiles.push(`${prefix}${entry}`); - } - } - } - listMdFiles(AGENT_NOTES_DIR, ''); - } - } catch { /* ignore */ } - - if (otherFiles.length > 0) { - sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`); - } - - if (sections.length === 0) return null; - return `# Agent Memory\n\n${sections.join('\n\n')}`; -} - function formatCurrentDateTime(now: Date): string { return now.toLocaleString('en-US', { weekday: 'long', @@ -1312,11 +1240,11 @@ export async function* streamAgent({ loopLogger.log('running llm turn'); // stream agent response and build message const messageBuilder = new StreamStepMessageBuilder(); - const composeCopilotContext = hasWorkspaceContext(state.agentName); + const workspace = loadWorkspaceContext(state.agentName, runId); const instructionsWithDateTime = composeSystemInstructions({ instructions: agent.instructions, - agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null, - userWorkDir: composeCopilotContext ? loadUserWorkDir(runId) : null, + agentNotesContext: workspace.agentNotesContext, + userWorkDir: workspace.userWorkDir, voiceInput, voiceOutput, searchEnabled, diff --git a/apps/x/packages/core/src/agents/workspace-context.test.ts b/apps/x/packages/core/src/agents/workspace-context.test.ts new file mode 100644 index 00000000..a185b6db --- /dev/null +++ b/apps/x/packages/core/src/agents/workspace-context.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { loadWorkspaceContext } from "./workspace-context.js"; + +const loaders = { + loadNotes: () => "# Agent Memory\n\nnotes", + loadWorkDir: (key: string) => (key === "chat-1" ? "/Users/me/work" : null), +}; + +describe("loadWorkspaceContext", () => { + it("loads notes and work dir for workspaceContext-trait agents", () => { + expect(loadWorkspaceContext("copilot", "chat-1", loaders)).toEqual({ + agentNotesContext: "# Agent Memory\n\nnotes", + userWorkDir: "/Users/me/work", + }); + // rowboatx is the copilot alias. + expect(loadWorkspaceContext("rowboatx", "chat-1", loaders).userWorkDir).toBe( + "/Users/me/work", + ); + }); + + it("returns nulls for non-workspace agents even when the loaders would yield values", () => { + // THE gate this module exists for: a caller cannot leak the user's + // agent-memory into a background/knowledge agent's prompt by + // forgetting a trait check — the check lives here. + for (const agentId of ["background-task-agent", "note_creation", "some-user-agent"]) { + expect(loadWorkspaceContext(agentId, "chat-1", loaders)).toEqual({ + agentNotesContext: null, + userWorkDir: null, + }); + } + expect(loadWorkspaceContext(null, "chat-1", loaders).agentNotesContext).toBeNull(); + expect(loadWorkspaceContext(undefined, "chat-1", loaders).agentNotesContext).toBeNull(); + }); + + it("skips the work-dir lookup without a key", () => { + expect(loadWorkspaceContext("copilot", null, loaders)).toEqual({ + agentNotesContext: "# Agent Memory\n\nnotes", + userWorkDir: null, + }); + }); +}); diff --git a/apps/x/packages/core/src/agents/workspace-context.ts b/apps/x/packages/core/src/agents/workspace-context.ts new file mode 100644 index 00000000..ea0039e1 --- /dev/null +++ b/apps/x/packages/core/src/agents/workspace-context.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; +import { WorkDir } from "../config/config.js"; +import { hasWorkspaceContext } from "./registry.js"; + +// Workspace context for agent assembly: agent notes (global) and the user's +// per-chat work directory. loadWorkspaceContext is THE chokepoint — the +// workspaceContext trait gate lives inside it, so no assembly site can +// forget the gate and leak the user's agent-memory into a non-workspace +// agent's prompt (or silently omit it for the copilot). Loaders extracted +// verbatim from the legacy runtime file. + +const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); + +// Work directory is scoped per run (per chat). Each run gets its own sidecar +// config file so setting it in one chat does not leak into others. +function workDirConfigFile(runId: string): string { + return path.join(WorkDir, 'config', `workdir-${runId}.json`); +} + +export function loadUserWorkDir(runId: string): string | null { + try { + const file = workDirConfigFile(runId); + if (!fs.existsSync(file)) return null; + const raw = fs.readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw) as { path?: unknown }; + const value = typeof parsed.path === 'string' ? parsed.path.trim() : ''; + return value || null; + } catch { + return null; + } +} + +export function loadAgentNotesContext(): string | null { + const sections: string[] = []; + + const userFile = path.join(AGENT_NOTES_DIR, 'user.md'); + const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md'); + + try { + if (fs.existsSync(userFile)) { + const content = fs.readFileSync(userFile, 'utf-8').trim(); + if (content) { + sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`); + } + } + } catch { /* ignore */ } + + try { + if (fs.existsSync(prefsFile)) { + const content = fs.readFileSync(prefsFile, 'utf-8').trim(); + if (content) { + sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`); + } + } + } catch { /* ignore */ } + + // List other Agent Notes files for on-demand access + const otherFiles: string[] = []; + const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']); + try { + if (fs.existsSync(AGENT_NOTES_DIR)) { + function listMdFiles(dir: string, prefix: string) { + for (const entry of fs.readdirSync(dir)) { + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + listMdFiles(fullPath, `${prefix}${entry}/`); + } else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) { + otherFiles.push(`${prefix}${entry}`); + } + } + } + listMdFiles(AGENT_NOTES_DIR, ''); + } + } catch { /* ignore */ } + + if (otherFiles.length > 0) { + sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`); + } + + if (sections.length === 0) return null; + return `# Agent Memory\n\n${sections.join('\n\n')}`; +} + +export interface WorkspaceContext { + agentNotesContext: string | null; + userWorkDir: string | null; +} + +const NO_WORKSPACE: WorkspaceContext = { + agentNotesContext: null, + userWorkDir: null, +}; + +// workDirKey is the per-chat work-directory sidecar key: the composition's +// workDirId on the turn runtime, the runId on the legacy engine. +export function loadWorkspaceContext( + agentId: string | null | undefined, + workDirKey: string | null | undefined, + loaders: { + loadNotes?: typeof loadAgentNotesContext; + loadWorkDir?: typeof loadUserWorkDir; + } = {}, +): WorkspaceContext { + if (!hasWorkspaceContext(agentId)) { + return NO_WORKSPACE; + } + const loadNotes = loaders.loadNotes ?? loadAgentNotesContext; + const loadWorkDir = loaders.loadWorkDir ?? loadUserWorkDir; + return { + agentNotesContext: loadNotes(), + userWorkDir: workDirKey ? loadWorkDir(workDirKey) : null, + }; +} diff --git a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts index 6992e958..635906b9 100644 --- a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts +++ b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts @@ -10,7 +10,8 @@ import { composeSystemInstructions } from "../../agents/compose-instructions.js" import { loadAgentNotesContext, loadUserWorkDir, -} from "../../agents/runtime.js"; + loadWorkspaceContext, +} from "../../agents/workspace-context.js"; import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js"; import { BuiltinTools } from "../../application/lib/builtin-tools.js"; import { skillToolNames } from "../../application/assistant/skills/index.js"; @@ -126,14 +127,16 @@ export class RealAgentResolver { ? parsed.data : CompositionOverrides.parse({}); const { workDirId, subagent, activeSkills, ...modeFlags } = composition; - // Agent notes and work-dir context are scoped to agents with the - // workspaceContext trait (the copilot), per the agent registry. + // The workspaceContext trait gate lives INSIDE loadWorkspaceContext + // (agents/workspace-context.ts) — no assembly site can forget it. + const workspace = loadWorkspaceContext(requested.agentId, workDirId, { + loadNotes: this.loadNotes, + loadWorkDir: this.loadWorkDir, + }); const copilotContext = hasWorkspaceContext(requested.agentId); const systemPrompt = composeSystemInstructions({ instructions: agent.instructions, - agentNotesContext: copilotContext ? this.loadNotes() : null, - userWorkDir: - copilotContext && workDirId ? this.loadWorkDir(workDirId) : null, + ...workspace, ...modeFlags, });