/** * @file A workspace's own context, like a Claude Desktop project's: its * Instructions (`lanes.instructions`), extra Folders and uploaded Context files * (`lane_sources`), its read-only Memory, and what all of it adds to a pane at * spawn (`launchExtras`). Everything a spawn reads comes from these rows, never * from a request. An extra folder must exist, be a directory, not be the * workspace's own folder and not be or contain the OS home; an uploaded file * is stored by AgentHub under `/workspace-context//` with a * name it chooses, so a request never names a path on disk. Only Claude * (`--add-dir`, the instructions as a CLAUDE.md in a folder of their own) and Codex (`--add-dir`, * `-c developer_instructions=`) can take them; every other tool is reported * unsupported and its panes get nothing. The workspace OWNER's own agent * instructions (`user_prefs.instructions`, Settings → My agent instructions) * go first, before the workspace's. * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { db } = require("../db"); const { getDataDir, getClaudeHome, encodeCwd } = require("./claude-home"); const { validateHomeDir } = require("./settings-env"); const SUPPORTED_TOOLS = new Set(["claude", "codex"]); const MAX_INSTRUCTIONS = 16_000; const MAX_FILE_BYTES = 10 * 1024 * 1024; const MAX_SOURCES = 50; const MAX_MEMORY_BYTES = 256 * 1024; // The whole `tmux new-session … -- argv` must fit in one tmux message (16 KB // on tmux 3.4, `command too long` past it), with PATH and the env beside it. // Claude reads the prompt from a file instead; Codex has no file form for // developer instructions, so past this it gets a pointer to the file. const INLINE_PROMPT_BYTES = 6 * 1024; const fail = (code, message) => Object.assign(new Error(message), { code }); function laneRow(laneId) { const lane = db.prepare("SELECT id, cwd, tool, instructions FROM lanes WHERE id = ?").get(laneId); if (!lane) throw fail("ENOLANE", "workspace not found"); return lane; } function contextRoot() { return path.join(getDataDir(), "workspace-context"); } function contextDir(laneId) { return path.join(contextRoot(), String(Number(laneId))); } function supports(tool) { return SUPPORTED_TOOLS.has(tool || "claude"); } function listSources(laneId) { laneRow(laneId); return db .prepare( "SELECT id, kind, path, name, size, added_at FROM lane_sources WHERE lane_id = ? ORDER BY id ASC" ) .all(laneId) .map((r) => ({ ...r, missing: !fs.existsSync(r.path) })); } function assertRoom(laneId) { const count = db .prepare("SELECT count(*) AS c FROM lane_sources WHERE lane_id = ?") .get(laneId).c; if (count >= MAX_SOURCES) throw fail("ETOOMANY", `a workspace holds at most ${MAX_SOURCES} sources`); } function insertSource(laneId, kind, p, name, size) { assertRoom(laneId); const info = db .prepare( "INSERT INTO lane_sources (lane_id, kind, path, name, size, added_at) VALUES (?, ?, ?, ?, ?, ?)" ) .run(laneId, kind, p, name, size ?? null, new Date().toISOString()); return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid); } /** `allowed(realPath)`: the caller's own boundary (a developer's home). */ function addFolder(laneId, input, { allowed = () => true } = {}) { const lane = laneRow(laneId); if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required"); let resolved; try { resolved = fs.realpathSync(validateHomeDir("a workspace folder", input)); } catch (err) { if (err.code === "EBADHOME") throw err; throw fail("EBADPATH", err.message); } let laneCwd = lane.cwd; try { laneCwd = fs.realpathSync(lane.cwd); } catch { /* a vanished workspace folder still has its recorded path */ } if (!allowed(resolved)) throw fail("EFOLDER", "an extra folder must be inside your own folder"); if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder"); const dup = db .prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?") .get(laneId, resolved); if (dup) throw fail("EDUP", "that folder is already a source"); return insertSource(laneId, "folder", resolved, path.basename(resolved) || resolved, null); } /** * An extra folder's directory for the git routes (`?source=`): only a * `folder` row of THIS workspace that is still a directory — the request names * a row id, never a path. * @throws ENOSOURCE otherwise. */ function sourceFolder(laneId, sourceId) { const row = /^\d+$/.test(String(sourceId)) ? db .prepare("SELECT path FROM lane_sources WHERE id = ? AND lane_id = ? AND kind = 'folder'") .get(Number(sourceId), laneId) : null; let dir = null; try { dir = row && fs.statSync(row.path).isDirectory() ? fs.realpathSync(row.path) : null; } catch { /* gone since it was added */ } if (!dir) throw fail("ENOSOURCE", "that folder is not one of this workspace's folders"); return dir; } /** A file name AgentHub can store: no directory part, no control or path characters. */ function safeName(raw) { const base = path.basename(String(raw || "")).replace(/[^\p{L}\p{N}._ -]/gu, "_"); const trimmed = base .replace(/^[.\s]+/, "") .trim() .slice(0, 120); return trimmed || "file"; } function addFile(laneId, rawName, buf) { laneRow(laneId); if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EEMPTY", "the file is empty"); if (buf.length > MAX_FILE_BYTES) { throw fail("ETOOBIG", `a context file may be at most ${MAX_FILE_BYTES / 1024 / 1024} MB`); } // Before the write: a refused upload must leave nothing in the dir the // agent is handed as --add-dir. assertRoom(laneId); const dir = contextDir(laneId); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const name = safeName(rawName); const ext = path.extname(name); const stem = name.slice(0, name.length - ext.length); let file = path.join(dir, name); for (let n = 2; fs.existsSync(file); n++) { const tag = n <= 99 ? `(${n})` : crypto.randomBytes(4).toString("hex"); file = path.join(dir, `${stem} ${tag}${ext}`); } fs.writeFileSync(file, buf, { mode: 0o600, flag: "wx" }); return insertSource(laneId, "file", file, path.basename(file), buf.length); } /** True when `p` is inside AgentHub's own context store — the only files it may delete. */ function ownsFile(p) { const root = contextRoot() + path.sep; return path.resolve(p).startsWith(root); } function removeSource(laneId, sourceId) { laneRow(laneId); const row = db .prepare("SELECT * FROM lane_sources WHERE id = ? AND lane_id = ?") .get(Number(sourceId), laneId); if (!row) throw fail("ENOSOURCE", "source not found"); // A folder is the user's; only AgentHub's own copy of an uploaded file goes. if (row.kind === "file" && ownsFile(row.path)) fs.rmSync(row.path, { force: true }); db.prepare("DELETE FROM lane_sources WHERE id = ?").run(row.id); } /** Drops AgentHub's context copies of a deleted workspace (its rows cascade). */ function removeLaneContext(laneId) { const dir = contextDir(laneId); if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true }); const prompts = path.join(getDataDir(), "workspace-prompts"); fs.rmSync(path.join(prompts, String(Number(laneId))), { recursive: true, force: true }); fs.rmSync(path.join(prompts, `${Number(laneId)}.md`), { force: true }); // before v2.8.9 } /** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */ function cleanInstructions(text) { if (text !== null && typeof text !== "string") { throw fail("EBADFIELD", "instructions must be a string or null"); } const value = text === null || !text.trim() ? null : text.replace(/\r\n/g, "\n"); if (value && value.length > MAX_INSTRUCTIONS) { throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`); } return value; } function setInstructions(laneId, text) { laneRow(laneId); const value = cleanInstructions(text); db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId); } function setUserInstructions(userKey, text) { const value = cleanInstructions(text); db.prepare( `INSERT INTO user_prefs (user_key, instructions) VALUES (?, ?) ON CONFLICT(user_key) DO UPDATE SET instructions = excluded.instructions, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` ).run(userKey, value); } function getUserInstructions(userKey) { return ( db.prepare("SELECT instructions FROM user_prefs WHERE user_key = ?").get(userKey) ?.instructions || null ); } /** The owner's own instructions: `local` in local mode, `u` in server mode. */ function ownerInstructions(ownerId) { if (!require("./auth").isServerMode()) return getUserInstructions("local"); return ownerId ? getUserInstructions(`u${ownerId}`) : null; } /** * What a pane of this workspace is started with, read from its rows: the * extra folders and (when any file was uploaded) the context dir to allow, and * the text to append to the system prompt — the instructions, then the list of * context files so the agent knows they exist. Sources that vanished are * skipped, not fatal: a deleted folder must not stop the workspace starting. */ function launchExtras(laneId, tool) { const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null }; if (!supports(tool)) return empty; const lane = db.prepare("SELECT instructions, owner_id FROM lanes WHERE id = ?").get(laneId); if (!lane) return empty; const rows = db .prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC") .all(laneId); const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } }; const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path); let files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path); // S3: a member's pane cannot reach the dashboard's data dir — the context // files and the prompt are copied into the member's handoff dir instead. const member = require("./run-as").accountForLane(laneId); let ctx = contextDir(laneId); if (member && files.length) { // Written AS the member (`write-as`): the dashboard never writes in a home. const runAs = require("./run-as"); const sub = `context-${Number(laneId)}`; ctx = path.join(runAs.handoffDir(member), sub); runAs.removeAs(member, ctx); runAs.mkdirAs(member, ctx); files = files.map((f) => runAs.handoffWrite(member, path.join(sub, path.basename(f)), fs.readFileSync(f)) ); } const addDirs = [...folders, ...(files.length ? [ctx] : [])]; const parts = []; const mine = ownerInstructions(lane.owner_id); if (mine) parts.push(mine); if (lane.instructions) parts.push(lane.instructions); if (files.length) { parts.push( "Reference files for this workspace — read them when they are relevant:\n" + files.map((f) => `- ${f}`).join("\n") ); } if (folders.length) { parts.push("Additional folders you may use:\n" + folders.map((f) => `- ${f}`).join("\n")); } if (!parts.length) return { addDirs, systemPrompt: null, systemPromptFile: null }; const systemPrompt = parts.join("\n\n"); // Written at every spawn, so the pane reads what the rows say now. // A data dir that cannot be written costs the pane its instructions (all of // Claude's, Codex's past the inline budget), never its start — same as a // vanished source. // The file is a `CLAUDE.md` alone in a folder of its own: Claude reads it as // memory from that folder (`--add-dir` + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_ // CLAUDE_MD, pty-run.js), not as `--append-system-prompt-file` — a custom // system prompt is a launch flag Claude cannot carry over a restart, so it // refused `/tui` (switching renderer restarts the session) in every pane // that had instructions. Codex names the same file in its pointer line. try { const sub = `prompt-${Number(laneId)}`; if (member) { const runAs = require("./run-as"); runAs.mkdirAs(member, path.join(runAs.handoffDir(member), sub)); const file = runAs.handoffWrite(member, path.join(sub, "CLAUDE.md"), systemPrompt); return { addDirs, systemPrompt, systemPromptFile: file }; } const dir = path.join(getDataDir(), "workspace-prompts", String(Number(laneId))); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const systemPromptFile = path.join(dir, "CLAUDE.md"); fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 }); return { addDirs, systemPrompt, systemPromptFile }; } catch (err) { console.warn(`[lane-sources] workspace ${laneId}: prompt file not written:`, err.message); return { addDirs, systemPrompt, systemPromptFile: null }; } } /** `text` as a TOML basic string: JSON's escapes are TOML's, except that TOML * also refuses a raw DEL, which JSON.stringify leaves as is. */ function tomlString(text) { return JSON.stringify(text).replace(/\x7f/g, "\\u007f"); } /** The developer instructions Codex gets inline: the text itself while it is * small, else one line pointing at the file (see INLINE_PROMPT_BYTES), or * null when it is too long and no file was written. */ function codexInstructions(systemPrompt, systemPromptFile) { // Measured as sent: escaping can double the text (quotes, newlines) or more. if (Buffer.byteLength(tomlString(systemPrompt)) <= INLINE_PROMPT_BYTES) return systemPrompt; if (!systemPromptFile) return null; return `This workspace's instructions and reference files are in ${systemPromptFile} — read that file before anything else and follow it.`; } function readMemoryFile(label, file) { try { if (!fs.statSync(file).isFile()) return null; // no blocking open on a FIFO // S3: a file in a member's folder is read only when the OPEN file is a // regular file that member owns — a link they planted to /etc/shadow or // another member's login is never read with the dashboard's rights. const fd = require("./run-as").openSafe(file); try { const st = fs.fstatSync(fd); if (!st.isFile()) return null; const truncated = st.size > MAX_MEMORY_BYTES; const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES)); fs.readSync(fd, buf, 0, buf.length, 0); return { label, path: file, size: st.size, mtime: st.mtime.toISOString(), truncated, content: buf.toString("utf8"), }; } finally { fs.closeSync(fd); } } catch { return null; } } /** * The memory a workspace's agent already reads, read-only: the folder's * CLAUDE.md / AGENTS.md family, and (Claude) its auto-memory directory * `/projects//memory/`. Every path is derived from * the lane row; nothing is written. */ function memoryFiles(laneId) { const lane = laneRow(laneId); const out = []; const add = (label, file) => { const f = readMemoryFile(label, file); if (f) out.push(f); }; add("CLAUDE.md", path.join(lane.cwd, "CLAUDE.md")); add(".claude/CLAUDE.md", path.join(lane.cwd, ".claude", "CLAUDE.md")); add("CLAUDE.local.md", path.join(lane.cwd, "CLAUDE.local.md")); add("AGENTS.md", path.join(lane.cwd, "AGENTS.md")); if ((lane.tool || "claude") === "claude") { const dir = path.join(getClaudeHome(), "projects", encodeCwd(lane.cwd), "memory"); let names = []; try { names = fs .readdirSync(dir) .filter((n) => n.endsWith(".md")) .sort(); } catch { /* no auto memory yet */ } for (const n of names.slice(0, 50)) add(`memory/${n}`, path.join(dir, n)); } return out; } module.exports = { SUPPORTED_TOOLS, MAX_FILE_BYTES, MAX_INSTRUCTIONS, supports, contextDir, listSources, addFolder, sourceFolder, addFile, removeSource, removeLaneContext, setInstructions, launchExtras, setUserInstructions, getUserInstructions, codexInstructions, tomlString, INLINE_PROMPT_BYTES, memoryFiles, safeName, };