import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, readdirSync, } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { TOOL_DEFS, executeTool, setUserFolderToolDeps } from './user-folder.js'; import { recorder } from '../../engine/browser-recorder.js'; import type { ToolContext } from './core.js'; // ── Mock loadConfig: enable user scripts for these tool tests ───────────────── // The real loadConfig reads config.yaml which doesn't exist in CI / test env. // We need userScriptsEnabled: true so RunUserScript tests aren't blocked by the gate. const { mockedLoadConfig } = vi.hoisted(() => ({ mockedLoadConfig: vi.fn(), })); vi.mock('../../config.js', () => ({ loadConfig: mockedLoadConfig, })); // Default config for all tests: scripts enabled mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } }); // ── Fixtures ────────────────────────────────────────────────────────────────── const SCRIPT_FOO = `\ --- description: Log into example.com params: - name: date type: string --- async function main({ date }) { return { logged: true, date }; } `; const SCRIPT_BAR = `\ --- description: Check dashboard params: [] --- async function main() { return { dashboard: 'ok' }; } `; // Minimal script that the child runner can execute (must export a callable) const SCRIPT_NO_FM = `module.exports = async function main({ params }) { return 42; };`; // Script that always throws at runtime const SCRIPT_THROWS = `\ --- description: Always fails params: [] --- module.exports = async function main() { throw new Error('boom'); }; `; function buildCtx(extra: Partial = {}): ToolContext { return { workspacePath: '/tmp', editAllowed: false, ...extra, }; } // ── Setup ───────────────────────────────────────────────────────────────────── let tempDir: string; let userFolderRoot: string; const TEST_USER = 'user-abc'; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'uf-tool-test-')); userFolderRoot = join(tempDir, 'users'); mkdirSync(userFolderRoot, { recursive: true }); // Create user subdirs (including memory and trash for memory tool tests) mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'recordings'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'memory'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'trash'), { recursive: true }); // Inject deps (no sessRepo needed for these tests — scripts have no sessionProfileId) setUserFolderToolDeps({ sessRepo: null as never, masterKeyPath: '', userFolderRoot, }); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); // Reset deps setUserFolderToolDeps(null); // Cancel any lingering recorder state (tests use unique taskIds but clean up defensively) recorder.cancel('test-task-failure'); recorder.cancel('test-task-no-override'); }); // ── TOOL_DEFS ───────────────────────────────────────────────────────────────── describe('TOOL_DEFS', () => { it('exports ListUserAssets and RunUserScript', () => { expect(TOOL_DEFS).toHaveProperty('ListUserAssets'); expect(TOOL_DEFS).toHaveProperty('RunUserScript'); }); }); // ── ListUserAssets ──────────────────────────────────────────────────────────── describe('ListUserAssets', () => { it('lists browser-macros with descriptions and params', async () => { writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO); writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'bar.js'), SCRIPT_BAR); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx); expect(result).not.toBeNull(); expect(result!.isError).toBe(false); expect(result!.output).toContain('foo.js'); expect(result!.output).toContain('Log into example.com'); expect(result!.output).toContain('date:string'); expect(result!.output).toContain('bar.js'); expect(result!.output).toContain('Check dashboard'); expect(result!.output).toContain('Browser Macros (2)'); }); it('returns error when userId is missing', async () => { const ctx = buildCtx({ userId: undefined }); const result = await executeTool('ListUserAssets', {}, ctx); expect(result).not.toBeNull(); expect(result!.isError).toBe(true); expect(result!.output).toContain('authenticated'); }); it('cross-user access is denied (ctx.userId must match target folder)', async () => { // Create another user's folder mkdirSync(join(userFolderRoot, 'other-user', 'browser-macros'), { recursive: true }); writeFileSync( join(userFolderRoot, 'other-user', 'browser-macros', 'secret.js'), SCRIPT_FOO, ); // Logged in as TEST_USER but the tool always reads from ctx.userId, // so there is no way to list another user's folder. // Verify that the tool reads only TEST_USER's macros (0 macros). const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('Browser Macros (0)'); expect(result!.output).not.toContain('secret.js'); }); it('returns "all" categories when kind is omitted', async () => { writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('ListUserAssets', {}, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('Browser Macros'); expect(result!.output).toContain('Recordings'); // Retired categories must no longer appear expect(result!.output).not.toContain('Templates'); expect(result!.output).not.toContain('Scripts ('); }); }); // ── RunUserScript ───────────────────────────────────────────────────────────── describe('RunUserScript', () => { it('runs a fixture macro that returns a value', async () => { writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx); expect(result).not.toBeNull(); expect(result!.isError).toBe(false); expect(result!.output).toContain('42'); }); it('returns isError true with "param" in message for bad params', async () => { // Macro declares `date:string` but we pass wrong type writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool( 'RunUserScript', { name: 'foo', params: { date: 123 } }, // wrong type ctx, ); expect(result!.isError).toBe(true); expect(result!.output.toLowerCase()).toContain('param'); }); it('returns isError true with "not found" for missing script', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'nonexistent' }, ctx); expect(result!.isError).toBe(true); expect(result!.output.toLowerCase()).toContain('not found'); }); it('returns isError true with clear message when browser-macro needs session but sessRepo is unavailable', async () => { // Script with sessionProfileId — but sessRepo is null (no browser session infra) const SCRIPT_WITH_SESSION = `\ --- description: Needs login params: [] session_profile_id: 1 --- module.exports = async function main() { return 'ok'; }; `; mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true }); writeFileSync( join(userFolderRoot, TEST_USER, 'browser-macros', 'authed.js'), SCRIPT_WITH_SESSION, ); // Keep userFolderRoot but remove sessRepo (simulates missing browser session infra) setUserFolderToolDeps({ sessRepo: null as never, masterKeyPath: '', userFolderRoot, }); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'authed', kind: 'browser-macro' }, ctx); expect(result!.isError).toBe(true); // Should mention that deps are not configured and suggest BrowseWeb expect(result!.output).toContain('not configured'); expect(result!.output.toLowerCase()).toContain('browseweb'); }); it('on browser-macro failure, auto-enables recorder with .next suffix', async () => { mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true }); writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'boom.js'), SCRIPT_THROWS); const taskId = 'test-task-failure'; const ctx = buildCtx({ userId: TEST_USER, taskId }); // Ensure no pre-existing recorder state for this taskId recorder.cancel(taskId); const result = await executeTool('RunUserScript', { name: 'boom', kind: 'browser-macro' }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('boom.next.js'); expect(result!.output).toContain('recorder is now enabled'); // Recorder should now be active for this task with the .next recordTo expect(recorder.recordTo(taskId)).toBe('boom.next'); }); it('does not override existing recordTo when browser-macro fails', async () => { mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true }); writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'boom.js'), SCRIPT_THROWS); const taskId = 'test-task-no-override'; const ctx = buildCtx({ userId: TEST_USER, taskId }); // Pre-enable the recorder with a user-chosen recordTo recorder.enable(taskId, 'manual-rec'); const result = await executeTool('RunUserScript', { name: 'boom', kind: 'browser-macro' }, ctx); expect(result!.isError).toBe(true); // The recorder should still be set to the original value, not overridden expect(recorder.recordTo(taskId)).toBe('manual-rec'); }); // ── P1: path traversal protection ───────────────────────────────────────── it('rejects path traversal in RunUserScript name', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: '../../escape' }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toMatch(/invalid script name|outside owner folder|not found/); }); // ── Retirement (2026-06): plain-Node scripts/ are gone ───────────────────── it('a file living only in the retired scripts/ dir is no longer resolvable', async () => { mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true }); writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'legacy.js'), SCRIPT_NO_FM); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'legacy' }, ctx); expect(result!.isError).toBe(true); expect(result!.output.toLowerCase()).toContain('not found'); }); it('explicit kind: "script" is rejected with a pointer to Bash/Skills', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'whatever', kind: 'script' }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('retired'); expect(result!.output).toContain('Bash'); }); it('kind: "browser-macro" still accepted explicitly (back-compat)', async () => { writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'nofm', kind: 'browser-macro' }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('42'); }); }); // ── UpdateUserMemory ────────────────────────────────────────────────────────── describe('UpdateUserMemory', () => { it('upserts a memory entry and returns success message', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('UpdateUserMemory', { action: 'upsert', name: 'test-fact', type: 'user', description: 'A test fact', body: 'Details about the test fact.', }, ctx); expect(result).not.toBeNull(); expect(result!.isError).toBe(false); expect(result!.output).toContain('upserted'); expect(result!.output).toContain('test-fact'); }); it('returns error when userId is missing (owner gate)', async () => { const ctx = buildCtx({ userId: undefined }); const result = await executeTool('UpdateUserMemory', { action: 'upsert', name: 'x', type: 'user', description: 'x', body: 'x', }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('authenticated'); }); it('returns error for invalid type', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('UpdateUserMemory', { action: 'upsert', name: 'bad', type: 'bogus-type', description: 'X', body: 'Y', }, ctx); expect(result!.isError).toBe(true); expect(result!.output.toLowerCase()).toContain('type'); }); it('returns error for invalid name (path traversal chars)', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('UpdateUserMemory', { action: 'upsert', name: '../escape', type: 'user', description: 'X', body: 'Y', }, ctx); expect(result!.isError).toBe(true); expect(result!.output.toLowerCase()).toContain('name'); }); it('deletes an existing entry and returns success', async () => { const ctx = buildCtx({ userId: TEST_USER }); // First upsert await executeTool('UpdateUserMemory', { action: 'upsert', name: 'to-delete', type: 'feedback', description: 'Will be deleted', body: 'bye', }, ctx); // Then delete const result = await executeTool('UpdateUserMemory', { action: 'delete', name: 'to-delete', }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('deleted'); expect(result!.output).toContain('to-delete'); }); it('returns error when deleting a non-existent entry', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('UpdateUserMemory', { action: 'delete', name: 'nonexistent', }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('not found'); }); }); // ── ReadUserMemory ──────────────────────────────────────────────────────────── describe('ReadUserMemory', () => { it('reads an existing memory entry', async () => { const ctx = buildCtx({ userId: TEST_USER }); // Set up entry first await executeTool('UpdateUserMemory', { action: 'upsert', name: 'readable-fact', type: 'project', description: 'A readable fact', body: 'This is the body.', }, ctx); const result = await executeTool('ReadUserMemory', { name: 'readable-fact' }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('readable-fact'); expect(result!.output).toContain('project'); expect(result!.output).toContain('This is the body.'); }); it('returns error (404-style) for missing entry', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('ReadUserMemory', { name: 'missing-entry' }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('not found'); }); it('returns error when userId is missing (owner gate)', async () => { const ctx = buildCtx({ userId: undefined }); const result = await executeTool('ReadUserMemory', { name: 'anything' }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('authenticated'); }); }); // ── RunUserScript: per-user allowlist + audit log ──────────────────────────── describe('RunUserScript: tools.user_scripts_allow_userids', () => { // Override the default loadConfig mock for these tests; restored after. afterEach(() => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } }); }); it('refuses when the calling user is not in the allowlist', async () => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] }, }); writeFileSync( join(userFolderRoot, TEST_USER, 'browser-macros', 'noop.js'), `---\nparams: []\n---\nasync function main(){return 'ok';}\nmodule.exports=main;\n`, 'utf-8', ); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'noop' }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('user_scripts_allow_userids'); }); it('runs when the calling user is in the allowlist', async () => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true, userScriptsAllowUserids: [TEST_USER, 'someone-else'] }, }); writeFileSync( join(userFolderRoot, TEST_USER, 'browser-macros', 'ok.js'), `---\nparams: []\n---\nasync function main(){return 'allowed';}\nmodule.exports=main;\n`, 'utf-8', ); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'ok' }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('allowed'); }); it('empty allowlist is treated as "no gate" (only the global enable matters)', async () => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true, userScriptsAllowUserids: [] }, }); writeFileSync( join(userFolderRoot, TEST_USER, 'browser-macros', 'empty.js'), `---\nparams: []\n---\nasync function main(){return 'still ok';}\nmodule.exports=main;\n`, 'utf-8', ); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'empty' }, ctx); expect(result!.isError).toBe(false); }); }); // ── WriteUserScript ─────────────────────────────────────────────────────────── const VALID_SCRIPT = `\ --- description: Fetch and clean data params: - name: url type: string --- async function main({ params }) { return { fetched: params.url }; } `; const VALID_BROWSER_MACRO = `\ --- description: Open a page params: [] --- async function main({ context, params }) { const page = await context.newPage(); await page.goto('https://example.com'); return { ok: true }; } `; describe('WriteUserScript', () => { it('rejects the retired kind: "script" with a pointer to Skills/Bash', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'fetch-and-clean', content: VALID_SCRIPT, kind: 'script', }, ctx); expect(result).not.toBeNull(); expect(result!.isError).toBe(true); expect(result!.output).toContain('retired'); }); it('writes a browser-macro to browser-macros/', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'open-page', content: VALID_BROWSER_MACRO, kind: 'browser-macro', }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('browser-macros/open-page.js'); const { existsSync: checkExists } = await import('fs'); const written = join(userFolderRoot, TEST_USER, 'browser-macros', 'open-page.js'); expect(checkExists(written)).toBe(true); }); it('writes to browser-macros/ when kind is omitted', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'implicit-kind', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('browser-macros/implicit-kind.js'); }); it('accepts name with .js suffix (strips it)', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'my-script.js', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('browser-macros/my-script.js'); // Must not write "my-script.js.js" expect(result!.output).not.toContain('my-script.js.js'); }); it('rejects missing name', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { content: VALID_SCRIPT }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('"name"'); }); it('rejects non-slug name (slash)', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'foo/bar', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(true); expect(result!.output.toLowerCase()).toContain('alphanumeric'); }); it('rejects non-slug name (space)', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'my script', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(true); }); it('rejects path traversal via name', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: '../escape', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(true); }); it('rejects content without a main definition', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'no-main', content: 'function helper() { return 1; }', }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('main'); }); it('accepts module.exports = ... as a valid main definition', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'cjs-export', content: 'module.exports = async function main({ params }) { return 42; };', }, ctx); expect(result!.isError).toBe(false); }); it('rejects oversized content', async () => { const ctx = buildCtx({ userId: TEST_USER }); const huge = 'async function main() {}\n' + 'x'.repeat(256 * 1024); const result = await executeTool('WriteUserScript', { name: 'big-script', content: huge, }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('bytes'); }); it('rejects duplicate without overwrite', async () => { const ctx = buildCtx({ userId: TEST_USER }); // Write once await executeTool('WriteUserScript', { name: 'dup', content: VALID_SCRIPT }, ctx); // Write again without overwrite const result = await executeTool('WriteUserScript', { name: 'dup', content: VALID_SCRIPT }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('overwrite'); }); it('overwrites when overwrite: true', async () => { const ctx = buildCtx({ userId: TEST_USER }); await executeTool('WriteUserScript', { name: 'overwritable', content: VALID_SCRIPT }, ctx); const updated = VALID_SCRIPT.replace('fetched', 'updated'); const result = await executeTool('WriteUserScript', { name: 'overwritable', content: updated, overwrite: true, }, ctx); expect(result!.isError).toBe(false); const { readFileSync: rf } = await import('fs'); const written = join(userFolderRoot, TEST_USER, 'browser-macros', 'overwritable.js'); expect(rf(written, 'utf-8')).toContain('updated'); }); it('requires authenticated user', async () => { const ctx = buildCtx({ userId: undefined }); const result = await executeTool('WriteUserScript', { name: 'test', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(true); expect(result!.output).toContain('authenticated'); }); it('invokes auditLog on success', async () => { const auditCalls: Array<{ action: string; detail: Record }> = []; setUserFolderToolDeps({ sessRepo: null as never, masterKeyPath: '', userFolderRoot, auditLog: (action, detail) => { auditCalls.push({ action, detail: detail as Record }); }, }); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'audit-test', content: VALID_SCRIPT, }, ctx); expect(result!.isError).toBe(false); const entry = auditCalls.find(c => c.action === 'user_script_written'); expect(entry).toBeDefined(); expect(entry!.detail['userId']).toBe(TEST_USER); expect(entry!.detail['filename']).toBe('audit-test.js'); }); }); describe('RunUserScript: audit log hook', () => { afterEach(() => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } }); }); it('invokes the auditLog dep with ok=true after a successful run', async () => { const auditCalls: Array<{ action: string; detail: Record; jobId?: string | null }> = []; setUserFolderToolDeps({ sessRepo: null as never, masterKeyPath: '', userFolderRoot, auditLog: (action, detail, jobId) => { auditCalls.push({ action, detail: detail as Record, jobId }); }, }); writeFileSync( join(userFolderRoot, TEST_USER, 'browser-macros', 'audited.js'), `---\nparams: []\n---\nasync function main(){return 'audited ok';}\nmodule.exports=main;\n`, 'utf-8', ); const ctx = buildCtx({ userId: TEST_USER, taskId: 'task-123' }); const result = await executeTool('RunUserScript', { name: 'audited' }, ctx); expect(result!.isError).toBe(false); const run = auditCalls.find(c => c.action === 'user_script_run'); expect(run).toBeDefined(); expect(run!.detail['ok']).toBe(true); expect(run!.detail['userId']).toBe(TEST_USER); expect(run!.detail['scriptName']).toBe('audited'); expect(run!.jobId).toBe('task-123'); }); it('logs user_script_denied when the allowlist refuses the call', async () => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] }, }); const auditCalls: Array<{ action: string }> = []; setUserFolderToolDeps({ sessRepo: null as never, masterKeyPath: '', userFolderRoot, auditLog: (action) => { auditCalls.push({ action }); }, }); const ctx = buildCtx({ userId: TEST_USER }); await executeTool('RunUserScript', { name: 'whatever' }, ctx); expect(auditCalls.some(c => c.action === 'user_script_denied')).toBe(true); }); }); // ── ReadUserAgents / UpdateUserAgents ───────────────────────────────────────── describe('ReadUserAgents / UpdateUserAgents', () => { const agentsPath = () => join(userFolderRoot, TEST_USER, 'AGENTS.md'); it('ReadUserAgents returns a friendly note when AGENTS.md does not exist', async () => { const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('ReadUserAgents', {}, ctx); expect(r?.isError).toBe(false); expect(r?.output).toContain('does not exist yet'); }); it('append creates AGENTS.md when empty', async () => { const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: '# 方針\n常に日本語で答える' }, ctx); expect(r?.isError).toBe(false); expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n常に日本語で答える'); }); it('append adds a blank line between existing content and new text', async () => { writeFileSync(agentsPath(), '# 既存\nルール1'); const ctx = buildCtx({ userId: TEST_USER }); await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'ルール2' }, ctx); expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 既存\nルール1\n\nルール2'); }); it('ReadUserAgents returns the full current content', async () => { writeFileSync(agentsPath(), 'line A\nline B'); const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('ReadUserAgents', {}, ctx); expect(r?.output).toBe('line A\nline B'); }); it('replace swaps an exact unique match', async () => { writeFileSync(agentsPath(), '# 方針\n古い行\nおわり'); const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: '古い行', new_text: '新しい行' }, ctx); expect(r?.isError).toBe(false); expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n新しい行\nおわり'); }); it('replace does not interpret $-patterns in new_text', async () => { writeFileSync(agentsPath(), 'price: PLACEHOLDER yen'); const ctx = buildCtx({ userId: TEST_USER }); await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'PLACEHOLDER', new_text: '$100 & $&' }, ctx); expect(readFileSync(agentsPath(), 'utf-8')).toBe('price: $100 & $& yen'); }); it('replace errors when old_text is not found', async () => { writeFileSync(agentsPath(), 'something'); const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'missing', new_text: 'x' }, ctx); expect(r?.isError).toBe(true); expect(r?.output).toContain('not found'); }); it('replace errors when old_text matches more than once', async () => { writeFileSync(agentsPath(), 'dup\ndup'); const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'dup', new_text: 'x' }, ctx); expect(r?.isError).toBe(true); expect(r?.output).toContain('matches 2 times'); }); it('replace on an empty file is rejected with a hint to append', async () => { const ctx = buildCtx({ userId: TEST_USER }); const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'x', new_text: 'y' }, ctx); expect(r?.isError).toBe(true); expect(r?.output).toContain('append'); }); it('snapshots the prior version to trash/agents-history before overwriting', async () => { writeFileSync(agentsPath(), 'original content'); const ctx = buildCtx({ userId: TEST_USER }); await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'more' }, ctx); const histDir = join(userFolderRoot, TEST_USER, 'trash', 'agents-history'); const backups = readdirSync(histDir).filter((f) => f.startsWith('AGENTS.md.') && f.endsWith('.bak')); expect(backups.length).toBe(1); expect(readFileSync(join(histDir, backups[0]!), 'utf-8')).toBe('original content'); }); it('rejects when result would exceed the 64KB cap', async () => { const ctx = buildCtx({ userId: TEST_USER }); const big = 'x'.repeat(64 * 1024 + 1); const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: big }, ctx); expect(r?.isError).toBe(true); expect(r?.output).toContain('exceeds'); }); it('emits a user_agents_updated audit event', async () => { const auditCalls: Array<{ action: string; detail: object }> = []; setUserFolderToolDeps({ sessRepo: null as never, masterKeyPath: '', userFolderRoot, auditLog: (action, detail) => auditCalls.push({ action, detail }), }); const ctx = buildCtx({ userId: TEST_USER }); await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'hello' }, ctx); expect(auditCalls.some((c) => c.action === 'user_agents_updated')).toBe(true); }); it('both tools require an authenticated user', async () => { const ctx = buildCtx({ userId: undefined }); const r1 = await executeTool('ReadUserAgents', {}, ctx); const r2 = await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'x' }, ctx); expect(r1?.isError).toBe(true); expect(r2?.isError).toBe(true); }); it('registers both tools in TOOL_DEFS', () => { expect(TOOL_DEFS['ReadUserAgents']).toBeDefined(); expect(TOOL_DEFS['UpdateUserAgents']).toBeDefined(); }); }); // ── spaces foundation: AGENTS.md / memory は folderContext を尊重する ────────── // バグ回帰: ケーススペースで作業中、AGENTS.md / memory が個人ユーザーフォルダ // (userFolderRoot/userId)に書かれてしまい、スペースの AGENTS.md を編集できなかった。 // folderContext が在れば (rootDir, leafId) を使い、無ければ従来の個人フォルダ。 describe('user-folder tools honor ctx.folderContext (space resolution)', () => { let spacesRoot: string; const SPACE_ID = 'space-xyz'; beforeEach(() => { spacesRoot = join(tempDir, 'spaces'); mkdirSync(join(spacesRoot, SPACE_ID, 'memory'), { recursive: true }); mkdirSync(join(spacesRoot, SPACE_ID, 'trash'), { recursive: true }); }); const spaceCtx = () => buildCtx({ userId: TEST_USER, folderContext: { rootDir: spacesRoot, leafId: SPACE_ID } }); it('UpdateUserAgents writes to the SPACE folder, not the personal user folder', async () => { const res = await executeTool( 'UpdateUserAgents', { mode: 'append', new_text: '# Space rules\nuse the space brief' }, spaceCtx(), ); expect(res.isError).toBe(false); // Written under spaces//AGENTS.md ... const spaceAgents = join(spacesRoot, SPACE_ID, 'AGENTS.md'); expect(readFileSync(spaceAgents, 'utf-8')).toContain('use the space brief'); // ... and NOT under the personal user folder. expect(() => readFileSync(join(userFolderRoot, TEST_USER, 'AGENTS.md'), 'utf-8')).toThrow(); }); it('ReadUserAgents reads the SPACE AGENTS.md', async () => { writeFileSync(join(spacesRoot, SPACE_ID, 'AGENTS.md'), 'SPACE-ONLY-MARKER'); // personal one would mislead if resolution were wrong writeFileSync(join(userFolderRoot, TEST_USER, 'AGENTS.md'), 'PERSONAL-MARKER'); const res = await executeTool('ReadUserAgents', {}, spaceCtx()); expect(res.output).toContain('SPACE-ONLY-MARKER'); expect(res.output).not.toContain('PERSONAL-MARKER'); }); it('UpdateUserMemory / ReadUserMemory target the SPACE memory dir', async () => { const up = await executeTool( 'UpdateUserMemory', { action: 'upsert', name: 'sp-note', type: 'project', description: 'd', body: 'space body' }, spaceCtx(), ); expect(up.isError).toBe(false); expect(readFileSync(join(spacesRoot, SPACE_ID, 'memory', 'sp-note.md'), 'utf-8')).toContain('space body'); // Not in the personal memory dir. expect(() => readFileSync(join(userFolderRoot, TEST_USER, 'memory', 'sp-note.md'), 'utf-8')).toThrow(); // And readable back through the space context. const rd = await executeTool('ReadUserMemory', { name: 'sp-note' }, spaceCtx()); expect(rd.isError).toBe(false); expect(rd.output).toContain('space body'); }); it('without folderContext, still writes to the personal user folder (legacy)', async () => { const res = await executeTool( 'UpdateUserAgents', { mode: 'append', new_text: 'personal rule' }, buildCtx({ userId: TEST_USER }), ); expect(res.isError).toBe(false); expect(readFileSync(join(userFolderRoot, TEST_USER, 'AGENTS.md'), 'utf-8')).toContain('personal rule'); }); // ── workstream 2: browser-macros / recordings honor folderContext ────────── it('WriteUserScript writes the macro into the SPACE browser-macros/, not personal', async () => { mkdirSync(join(spacesRoot, SPACE_ID, 'browser-macros'), { recursive: true }); const res = await executeTool( 'WriteUserScript', { name: 'space-macro', content: 'async function main() { return 1; }' }, spaceCtx(), ); expect(res.isError).toBe(false); expect(readFileSync(join(spacesRoot, SPACE_ID, 'browser-macros', 'space-macro.js'), 'utf-8')).toContain('main'); // NOT in the personal folder. expect(() => readFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'space-macro.js'), 'utf-8')).toThrow(); }); it('RunUserScript resolves the macro from the SPACE folder', async () => { mkdirSync(join(spacesRoot, SPACE_ID, 'browser-macros'), { recursive: true }); writeFileSync(join(spacesRoot, SPACE_ID, 'browser-macros', 'sp-run.js'), SCRIPT_NO_FM); const res = await executeTool('RunUserScript', { name: 'sp-run' }, spaceCtx()); expect(res.isError).toBe(false); expect(res.output).toContain('42'); }); it('ListUserAssets lists the SPACE browser-macros, not the personal ones', async () => { mkdirSync(join(spacesRoot, SPACE_ID, 'browser-macros'), { recursive: true }); writeFileSync(join(spacesRoot, SPACE_ID, 'browser-macros', 'sp-macro.js'), SCRIPT_BAR); writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'personal-macro.js'), SCRIPT_FOO); const res = await executeTool('ListUserAssets', { kind: 'browser-macros' }, spaceCtx()); expect(res.isError).toBe(false); expect(res.output).toContain('sp-macro.js'); expect(res.output).not.toContain('personal-macro.js'); }); it('without folderContext, WriteUserScript still targets the personal folder', async () => { const res = await executeTool( 'WriteUserScript', { name: 'personal-macro', content: 'async function main() { return 2; }' }, buildCtx({ userId: TEST_USER }), ); expect(res.isError).toBe(false); expect(readFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'personal-macro.js'), 'utf-8')).toContain('main'); }); });