Files
maestro/src/engine/tools/user-folder.test.ts
T
oss-sync 29ccaf1e92
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

1000 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}): 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<string, unknown> }> = [];
setUserFolderToolDeps({
sessRepo: null as never,
masterKeyPath: '',
userFolderRoot,
auditLog: (action, detail) => { auditCalls.push({ action, detail: detail as Record<string, unknown> }); },
});
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<string, unknown>; jobId?: string | null }> = [];
setUserFolderToolDeps({
sessRepo: null as never,
masterKeyPath: '',
userFolderRoot,
auditLog: (action, detail, jobId) => {
auditCalls.push({ action, detail: detail as Record<string, unknown>, 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/<id>/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');
});
});