1000 lines
39 KiB
TypeScript
1000 lines
39 KiB
TypeScript
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');
|
||
});
|
||
});
|