This commit is contained in:
@@ -81,9 +81,7 @@ beforeEach(() => {
|
||||
mkdirSync(userFolderRoot, { recursive: true });
|
||||
|
||||
// Create user subdirs (including memory and trash for memory tool tests)
|
||||
mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true });
|
||||
mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true });
|
||||
mkdirSync(join(userFolderRoot, TEST_USER, 'templates'), { 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 });
|
||||
@@ -117,12 +115,12 @@ describe('TOOL_DEFS', () => {
|
||||
// ── ListUserAssets ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ListUserAssets', () => {
|
||||
it('lists scripts with descriptions and params', async () => {
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO);
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'bar.js'), SCRIPT_BAR);
|
||||
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: 'scripts' }, ctx);
|
||||
const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isError).toBe(false);
|
||||
@@ -131,7 +129,7 @@ describe('ListUserAssets', () => {
|
||||
expect(result!.output).toContain('date:string');
|
||||
expect(result!.output).toContain('bar.js');
|
||||
expect(result!.output).toContain('Check dashboard');
|
||||
expect(result!.output).toContain('Scripts (2)');
|
||||
expect(result!.output).toContain('Browser Macros (2)');
|
||||
});
|
||||
|
||||
it('returns error when userId is missing', async () => {
|
||||
@@ -145,41 +143,43 @@ describe('ListUserAssets', () => {
|
||||
|
||||
it('cross-user access is denied (ctx.userId must match target folder)', async () => {
|
||||
// Create another user's folder
|
||||
mkdirSync(join(userFolderRoot, 'other-user', 'scripts'), { recursive: true });
|
||||
mkdirSync(join(userFolderRoot, 'other-user', 'browser-macros'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(userFolderRoot, 'other-user', 'scripts', 'secret.js'),
|
||||
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 scripts (0 scripts).
|
||||
// Verify that the tool reads only TEST_USER's macros (0 macros).
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('ListUserAssets', { kind: 'scripts' }, ctx);
|
||||
const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('Scripts (0)');
|
||||
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, 'scripts', 'foo.js'), SCRIPT_FOO);
|
||||
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('Scripts');
|
||||
expect(result!.output).toContain('Templates');
|
||||
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 script that returns a value', async () => {
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM);
|
||||
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);
|
||||
@@ -190,8 +190,8 @@ describe('RunUserScript', () => {
|
||||
});
|
||||
|
||||
it('returns isError true with "param" in message for bad params', async () => {
|
||||
// Script declares `date:string` but we pass wrong type
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO);
|
||||
// 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(
|
||||
@@ -289,67 +289,34 @@ module.exports = async function main() { return 'ok'; };
|
||||
expect(result!.output).toMatch(/invalid script name|outside owner folder|not found/);
|
||||
});
|
||||
|
||||
// ── P2a: kind fallback (undefined → search scripts/ then browser-macros/) ─
|
||||
// ── Retirement (2026-06): plain-Node scripts/ are gone ─────────────────────
|
||||
|
||||
it('resolves to scripts/ first when kind omitted', async () => {
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM);
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_THROWS);
|
||||
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 });
|
||||
// kind omitted → should find scripts/nofm.js (plain runtime, returns 42)
|
||||
const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx);
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('42');
|
||||
const result = await executeTool('RunUserScript', { name: 'legacy' }, ctx);
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output.toLowerCase()).toContain('not found');
|
||||
});
|
||||
|
||||
it('falls back to browser-macros/ when kind omitted and not in scripts/', async () => {
|
||||
// Only exists in browser-macros/
|
||||
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' }, ctx);
|
||||
// Should find it in browser-macros/ (plain-compatible SCRIPT_NO_FM, no playwright deps)
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('42');
|
||||
});
|
||||
|
||||
it('kind: "browser-macro" goes straight to browser-macros/', async () => {
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_THROWS);
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
// Explicit kind=browser-macro should use browser-macros/ not scripts/
|
||||
const result = await executeTool('RunUserScript', { name: 'nofm', kind: 'browser-macro' }, ctx);
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('42');
|
||||
});
|
||||
|
||||
it('returns error mentioning both subdirs when kind omitted and not found', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RunUserScript', { name: 'nonexistent' }, ctx);
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('scripts/');
|
||||
expect(result!.output).toContain('browser-macros/');
|
||||
});
|
||||
|
||||
// ── session_profile_id in scripts/ (plain runtime) → friendly error ───────
|
||||
|
||||
it('rejects scripts/*.js with session_profile_id (plain runtime mismatch)', async () => {
|
||||
const scriptWithSession = `\
|
||||
---
|
||||
description: Mistakenly placed browser macro
|
||||
session_profile_id: 3
|
||||
---
|
||||
module.exports = async function main() { return 'unreachable'; };
|
||||
`;
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'wrongplace.js'), scriptWithSession);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RunUserScript', { name: 'wrongplace', kind: 'script' }, ctx);
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toMatch(/session_profile_id/);
|
||||
expect(result!.output).toMatch(/browser-macros/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── UpdateUserMemory ──────────────────────────────────────────────────────────
|
||||
@@ -447,232 +414,6 @@ describe('UpdateUserMemory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReadUserTemplate ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('ReadUserTemplate', () => {
|
||||
it('reads a plain markdown template by name', async () => {
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'templates', 'weekly-report.md'),
|
||||
'# Weekly Report\n\nFill in this week\'s highlights here.\n',
|
||||
);
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('ReadUserTemplate', { name: 'weekly-report' }, ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('# Template: weekly-report');
|
||||
expect(result!.output).toContain('Fill in this week\'s highlights here.');
|
||||
});
|
||||
|
||||
it('reads a template with frontmatter', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'title: API Error Email',
|
||||
'audience: external',
|
||||
'---',
|
||||
'Dear customer,',
|
||||
'',
|
||||
'We apologize for the inconvenience.',
|
||||
].join('\n') + '\n';
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'templates', 'api-error-email.md'),
|
||||
content,
|
||||
);
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('ReadUserTemplate', { name: 'api-error-email' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('## Frontmatter');
|
||||
expect(result!.output).toContain('title');
|
||||
expect(result!.output).toContain('API Error Email');
|
||||
expect(result!.output).toContain('Dear customer,');
|
||||
});
|
||||
|
||||
it('returns error for missing template', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('ReadUserTemplate', { name: 'nonexistent' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('not found');
|
||||
});
|
||||
|
||||
it('rejects path traversal in name', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('ReadUserTemplate', { name: '../escape' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('handles name with .md suffix gracefully', async () => {
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'templates', 'boilerplate.md'),
|
||||
'Hello world template.\n',
|
||||
);
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
// Pass name with .md extension — should still work
|
||||
const result = await executeTool('ReadUserTemplate', { name: 'boilerplate.md' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('Hello world template.');
|
||||
});
|
||||
|
||||
it('rejects without authenticated user', async () => {
|
||||
const ctx = buildCtx({ userId: undefined });
|
||||
const result = await executeTool('ReadUserTemplate', { name: 'anything' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('authenticated');
|
||||
});
|
||||
});
|
||||
|
||||
// ── RenderUserTemplate ────────────────────────────────────────────────────────
|
||||
|
||||
describe('RenderUserTemplate', () => {
|
||||
it('substitutes {{var}} declared in frontmatter.params', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: Weekly report',
|
||||
'params:',
|
||||
' - name: date',
|
||||
' type: string',
|
||||
' - name: summary',
|
||||
' type: string',
|
||||
'---',
|
||||
'On {{date}}: {{summary}}',
|
||||
].join('\n') + '\n';
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'weekly.md'), content);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool(
|
||||
'RenderUserTemplate',
|
||||
{ name: 'weekly', params: { date: '2026-05-11', summary: 'shipped 3 PRs' } },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toBe('On 2026-05-11: shipped 3 PRs\n');
|
||||
});
|
||||
|
||||
it('applies declared defaults when params are omitted', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: Greeting',
|
||||
'params:',
|
||||
' - name: name',
|
||||
' type: string',
|
||||
' default: world',
|
||||
'---',
|
||||
'Hello, {{name}}!',
|
||||
].join('\n') + '\n';
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'greet.md'), content);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RenderUserTemplate', { name: 'greet' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toBe('Hello, world!\n');
|
||||
});
|
||||
|
||||
it('leaves undeclared {{var}} literal', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: Mixed',
|
||||
'params:',
|
||||
' - name: title',
|
||||
' type: string',
|
||||
'---',
|
||||
'# {{title}}\n\nSee {{see_also}} for details.',
|
||||
].join('\n') + '\n';
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'mixed.md'), content);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool(
|
||||
'RenderUserTemplate',
|
||||
{ name: 'mixed', params: { title: 'Notes' } },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('# Notes');
|
||||
expect(result!.output).toContain('See {{see_also}} for details.');
|
||||
});
|
||||
|
||||
it('rejects when a required param is missing', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: Required param',
|
||||
'params:',
|
||||
' - name: subject',
|
||||
' type: string',
|
||||
'---',
|
||||
'Subject: {{subject}}',
|
||||
].join('\n') + '\n';
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'req.md'), content);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RenderUserTemplate', { name: 'req' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toMatch(/subject.*required/i);
|
||||
});
|
||||
|
||||
it('rejects type-mismatched params', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: Number param',
|
||||
'params:',
|
||||
' - name: count',
|
||||
' type: number',
|
||||
'---',
|
||||
'Total: {{count}}',
|
||||
].join('\n') + '\n';
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'count.md'), content);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool(
|
||||
'RenderUserTemplate',
|
||||
{ name: 'count', params: { count: 'three' } },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toMatch(/count.*expected number/);
|
||||
});
|
||||
|
||||
it('renders a template without frontmatter as-is', async () => {
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'templates', 'plain.md'),
|
||||
'Just plain text with {{notRendered}}.\n',
|
||||
);
|
||||
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RenderUserTemplate', { name: 'plain' }, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toBe('Just plain text with {{notRendered}}.\n');
|
||||
});
|
||||
|
||||
it('rejects path traversal', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RenderUserTemplate', { name: '../escape' }, ctx);
|
||||
expect(result!.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('returns error for missing template', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('RenderUserTemplate', { name: 'never-existed' }, ctx);
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('not found');
|
||||
});
|
||||
|
||||
it('rejects without authenticated user', async () => {
|
||||
const ctx = buildCtx({ userId: undefined });
|
||||
const result = await executeTool('RenderUserTemplate', { name: 'anything' }, ctx);
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('authenticated');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReadUserMemory ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ReadUserMemory', () => {
|
||||
@@ -725,7 +466,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => {
|
||||
tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] },
|
||||
});
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'scripts', 'noop.js'),
|
||||
join(userFolderRoot, TEST_USER, 'browser-macros', 'noop.js'),
|
||||
`---\nparams: []\n---\nasync function main(){return 'ok';}\nmodule.exports=main;\n`,
|
||||
'utf-8',
|
||||
);
|
||||
@@ -740,7 +481,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => {
|
||||
tools: { userScriptsEnabled: true, userScriptsAllowUserids: [TEST_USER, 'someone-else'] },
|
||||
});
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'scripts', 'ok.js'),
|
||||
join(userFolderRoot, TEST_USER, 'browser-macros', 'ok.js'),
|
||||
`---\nparams: []\n---\nasync function main(){return 'allowed';}\nmodule.exports=main;\n`,
|
||||
'utf-8',
|
||||
);
|
||||
@@ -755,7 +496,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => {
|
||||
tools: { userScriptsEnabled: true, userScriptsAllowUserids: [] },
|
||||
});
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'scripts', 'empty.js'),
|
||||
join(userFolderRoot, TEST_USER, 'browser-macros', 'empty.js'),
|
||||
`---\nparams: []\n---\nasync function main(){return 'still ok';}\nmodule.exports=main;\n`,
|
||||
'utf-8',
|
||||
);
|
||||
@@ -792,7 +533,7 @@ async function main({ context, params }) {
|
||||
`;
|
||||
|
||||
describe('WriteUserScript', () => {
|
||||
it('writes a plain script to scripts/', async () => {
|
||||
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',
|
||||
@@ -801,13 +542,8 @@ describe('WriteUserScript', () => {
|
||||
}, ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('scripts/fetch-and-clean.js');
|
||||
|
||||
const written = join(userFolderRoot, TEST_USER, 'scripts', 'fetch-and-clean.js');
|
||||
const { existsSync: checkExists, readFileSync: rf } = await import('fs');
|
||||
expect(checkExists(written)).toBe(true);
|
||||
expect(rf(written, 'utf-8')).toBe(VALID_SCRIPT);
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('retired');
|
||||
});
|
||||
|
||||
it('writes a browser-macro to browser-macros/', async () => {
|
||||
@@ -826,7 +562,7 @@ describe('WriteUserScript', () => {
|
||||
expect(checkExists(written)).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults kind to "script" when omitted', async () => {
|
||||
it('writes to browser-macros/ when kind is omitted', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('WriteUserScript', {
|
||||
name: 'implicit-kind',
|
||||
@@ -834,7 +570,7 @@ describe('WriteUserScript', () => {
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('scripts/implicit-kind.js');
|
||||
expect(result!.output).toContain('browser-macros/implicit-kind.js');
|
||||
});
|
||||
|
||||
it('accepts name with .js suffix (strips it)', async () => {
|
||||
@@ -845,7 +581,7 @@ describe('WriteUserScript', () => {
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('scripts/my-script.js');
|
||||
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');
|
||||
});
|
||||
@@ -946,7 +682,7 @@ describe('WriteUserScript', () => {
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
const { readFileSync: rf } = await import('fs');
|
||||
const written = join(userFolderRoot, TEST_USER, 'scripts', 'overwritable.js');
|
||||
const written = join(userFolderRoot, TEST_USER, 'browser-macros', 'overwritable.js');
|
||||
expect(rf(written, 'utf-8')).toContain('updated');
|
||||
});
|
||||
|
||||
@@ -984,166 +720,6 @@ describe('WriteUserScript', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── WriteUserTemplate ─────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_TEMPLATE = `\
|
||||
---
|
||||
description: Weekly report
|
||||
params:
|
||||
- name: date
|
||||
type: string
|
||||
---
|
||||
# Weekly Report — {{date}}
|
||||
|
||||
Highlights this week:
|
||||
- ...
|
||||
`;
|
||||
|
||||
describe('WriteUserTemplate', () => {
|
||||
it('writes a template to templates/', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('WriteUserTemplate', {
|
||||
name: 'weekly-report',
|
||||
content: VALID_TEMPLATE,
|
||||
}, ctx);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('templates/weekly-report.md');
|
||||
|
||||
const { existsSync: checkExists, readFileSync: rf } = await import('fs');
|
||||
const written = join(userFolderRoot, TEST_USER, 'templates', 'weekly-report.md');
|
||||
expect(checkExists(written)).toBe(true);
|
||||
expect(rf(written, 'utf-8')).toBe(VALID_TEMPLATE);
|
||||
});
|
||||
|
||||
it('accepts name with .md suffix (strips it)', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('WriteUserTemplate', {
|
||||
name: 'report.md',
|
||||
content: VALID_TEMPLATE,
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('templates/report.md');
|
||||
// Must not write "report.md.md"
|
||||
expect(result!.output).not.toContain('report.md.md');
|
||||
});
|
||||
|
||||
it('rejects missing name', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('WriteUserTemplate', { content: VALID_TEMPLATE }, 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('WriteUserTemplate', {
|
||||
name: 'foo/bar',
|
||||
content: VALID_TEMPLATE,
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output.toLowerCase()).toContain('alphanumeric');
|
||||
});
|
||||
|
||||
it('rejects path traversal via name', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const result = await executeTool('WriteUserTemplate', {
|
||||
name: '../escape',
|
||||
content: VALID_TEMPLATE,
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects oversized content', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const huge = '# template\n' + 'x'.repeat(128 * 1024);
|
||||
const result = await executeTool('WriteUserTemplate', {
|
||||
name: 'big-template',
|
||||
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 });
|
||||
await executeTool('WriteUserTemplate', { name: 'dup', content: VALID_TEMPLATE }, ctx);
|
||||
const result = await executeTool('WriteUserTemplate', { name: 'dup', content: VALID_TEMPLATE }, 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('WriteUserTemplate', { name: 'overwritable-tmpl', content: VALID_TEMPLATE }, ctx);
|
||||
|
||||
const updated = VALID_TEMPLATE.replace('Weekly Report', 'Monthly Report');
|
||||
const result = await executeTool('WriteUserTemplate', {
|
||||
name: 'overwritable-tmpl',
|
||||
content: updated,
|
||||
overwrite: true,
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
const { readFileSync: rf } = await import('fs');
|
||||
const written = join(userFolderRoot, TEST_USER, 'templates', 'overwritable-tmpl.md');
|
||||
expect(rf(written, 'utf-8')).toContain('Monthly Report');
|
||||
});
|
||||
|
||||
it('requires authenticated user', async () => {
|
||||
const ctx = buildCtx({ userId: undefined });
|
||||
const result = await executeTool('WriteUserTemplate', {
|
||||
name: 'test',
|
||||
content: VALID_TEMPLATE,
|
||||
}, 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('WriteUserTemplate', {
|
||||
name: 'audit-tmpl',
|
||||
content: VALID_TEMPLATE,
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
const entry = auditCalls.find(c => c.action === 'user_template_written');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.detail['userId']).toBe(TEST_USER);
|
||||
expect(entry!.detail['filename']).toBe('audit-tmpl.md');
|
||||
});
|
||||
|
||||
it('written template is immediately readable via ReadUserTemplate', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('WriteUserTemplate', {
|
||||
name: 'round-trip',
|
||||
content: VALID_TEMPLATE,
|
||||
}, ctx);
|
||||
|
||||
const result = await executeTool('ReadUserTemplate', { name: 'round-trip' }, ctx);
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('Weekly Report');
|
||||
expect(result!.output).toContain('{{date}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RunUserScript: audit log hook', () => {
|
||||
afterEach(() => {
|
||||
mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } });
|
||||
@@ -1160,7 +736,7 @@ describe('RunUserScript: audit log hook', () => {
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
join(userFolderRoot, TEST_USER, 'scripts', 'audited.js'),
|
||||
join(userFolderRoot, TEST_USER, 'browser-macros', 'audited.js'),
|
||||
`---\nparams: []\n---\nasync function main(){return 'audited ok';}\nmodule.exports=main;\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
+41
-416
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* user-folder.ts
|
||||
*
|
||||
* Tools for discovering and executing user-authored Playwright scripts:
|
||||
* - ListUserAssets: browse scripts / templates / recordings in data/users/{userId}/
|
||||
* - RunUserScript: validate params, decrypt session storageState if needed, delegate to runUserScript()
|
||||
* Tools for the per-user folder (data/users/{userId}/):
|
||||
* - UpdateUserMemory / ReadUserMemory: persistent memory entries
|
||||
* - ListUserAssets: browse browser-macros / recordings
|
||||
* - RunUserScript / WriteUserScript: Playwright browser-macros
|
||||
*
|
||||
* Note: plain-Node scripts/ and templates/ tools were retired in 2026-06 —
|
||||
* reusable knowledge belongs in Skills, ad-hoc code runs via the Bash tool.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import { ToolContext, ToolResult } from './core.js';
|
||||
import { loadConfig } from '../../config.js';
|
||||
import { parseScript } from '../../user-folder/frontmatter.js';
|
||||
import { renderTemplate } from '../../user-folder/template-renderer.js';
|
||||
import {
|
||||
userRoot,
|
||||
assertOwnerAccess,
|
||||
@@ -62,9 +64,6 @@ function getUserFolderRoot(): string {
|
||||
/** Regex for valid memory entry names: alphanumeric, dash, underscore; no extension. */
|
||||
const MEMORY_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Regex for valid template names: alphanumeric, dash, underscore, dot; no path separators. */
|
||||
const TEMPLATE_NAME_RE = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
UpdateUserMemory: {
|
||||
type: 'function',
|
||||
@@ -125,65 +124,19 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
},
|
||||
|
||||
ReadUserTemplate: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ReadUserTemplate',
|
||||
description:
|
||||
'Loads a template file from the caller\'s templates/ subdir. ' +
|
||||
'Returns the raw body (frontmatter optional). Useful for boilerplate / report templates / canned snippets. ' +
|
||||
'Details via ReadToolDoc({ name: "ReadUserTemplate" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Template file name (with or without .md extension).',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
RenderUserTemplate: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'RenderUserTemplate',
|
||||
description:
|
||||
'Renders a template from templates/ by substituting {{var}} placeholders with caller-supplied params. ' +
|
||||
'Frontmatter params spec is validated and defaults are applied. Unknown placeholders are left literal. ' +
|
||||
'Details via ReadToolDoc({ name: "RenderUserTemplate" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Template file name (with or without .md extension).',
|
||||
},
|
||||
params: {
|
||||
type: 'object',
|
||||
description: 'Key-value params matching the template\'s frontmatter param spec.',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ListUserAssets: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ListUserAssets',
|
||||
description:
|
||||
'Lists user-authored scripts, browser-macros, templates, and recordings in the caller\'s folder. ' +
|
||||
'Lists user-authored browser-macros and recordings in the caller\'s folder. ' +
|
||||
'Details via ReadToolDoc({ name: "ListUserAssets" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['scripts', 'browser-macros', 'templates', 'recordings', 'all'],
|
||||
enum: ['browser-macros', 'recordings', 'all'],
|
||||
description: 'Which category to list. Default "all".',
|
||||
},
|
||||
},
|
||||
@@ -197,27 +150,20 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
function: {
|
||||
name: 'RunUserScript',
|
||||
description:
|
||||
'Executes a user-authored script from the caller\'s user folder. ' +
|
||||
'Use kind="script" (default) for plain Node scripts in scripts/, ' +
|
||||
'or kind="browser-macro" for Playwright scripts in browser-macros/. ' +
|
||||
'Node only — NOT for Python: plain scripts run under Node --permission (no child_process), ' +
|
||||
'so a JS wrapper that shells out to python WILL fail; run Python directly with the Bash tool (pip pre-baked). ' +
|
||||
'Executes a user-authored Playwright browser-macro from the caller\'s browser-macros/ folder ' +
|
||||
'(main({ context, params }) signature, optional session_profile_id frontmatter). ' +
|
||||
'For ad-hoc code (Node, Python, …) use the Bash tool instead. ' +
|
||||
'Details via ReadToolDoc({ name: "RunUserScript" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Script filename (with or without .js extension).',
|
||||
description: 'Macro filename (with or without .js extension).',
|
||||
},
|
||||
params: {
|
||||
type: 'object',
|
||||
description: 'Key-value params matching the script\'s frontmatter param spec.',
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['script', 'browser-macro'],
|
||||
description: '"script" (default): plain Node, scripts/ dir, main({ params }). "browser-macro": Playwright, browser-macros/ dir, main({ context, params }).',
|
||||
description: 'Key-value params matching the macro\'s frontmatter param spec.',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
@@ -230,22 +176,16 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
function: {
|
||||
name: 'WriteUserScript',
|
||||
description:
|
||||
'ユーザーフォルダの scripts/ または browser-macros/ に script を作成・上書きする。' +
|
||||
'kind="script" は scripts/ (plain Node、main({ params }) 形式)、' +
|
||||
'kind="browser-macro" は browser-macros/ (Playwright、main({ context, params }) 形式)。' +
|
||||
'Node 専用 — Python 不可: python を呼ぶだけの JS ラッパーを書かないこと (plain は --permission で child_process 不可)。python は Bash ツールで直接実行する (pip は pre-baked)。' +
|
||||
'ユーザーフォルダの browser-macros/ に Playwright マクロを作成・上書きする ' +
|
||||
'(main({ context, params }) 形式)。' +
|
||||
'アドホックなコード実行 (Node / Python 等) は Bash ツールを使うこと。' +
|
||||
'詳細は ReadToolDoc({ name: "WriteUserScript" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'ファイル名 (slug)。`.js` は自動補完される。例: "fetch-and-clean"',
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['script', 'browser-macro'],
|
||||
description: '"script" (default、plain Node) または "browser-macro" (Playwright)',
|
||||
description: 'ファイル名 (slug)。`.js` は自動補完される。例: "nightly-login-check"',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
@@ -260,35 +200,6 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
WriteUserTemplate: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'WriteUserTemplate',
|
||||
description:
|
||||
'ユーザーフォルダの templates/ にテンプレートを作成・上書きする。' +
|
||||
'本文は Markdown、frontmatter で params 仕様を宣言可能 (ReadUserTemplate/RenderUserTemplate と互換)。' +
|
||||
'詳細は ReadToolDoc({ name: "WriteUserTemplate" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'ファイル名 (slug)。`.md` は自動補完される',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'ファイル全文。frontmatter + 本文',
|
||||
},
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
description: '既存ファイルを上書きするかどうか (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['name', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── UpdateUserMemory implementation ──────────────────────────────────────────
|
||||
@@ -398,156 +309,6 @@ async function executeReadUserMemory(
|
||||
return { output, isError: false };
|
||||
}
|
||||
|
||||
// ── ReadUserTemplate implementation ──────────────────────────────────────────
|
||||
|
||||
async function executeReadUserTemplate(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'ReadUserTemplate requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
const rawName = input['name'];
|
||||
if (typeof rawName !== 'string' || !rawName.trim()) {
|
||||
return { output: 'ReadUserTemplate: "name" parameter is required', isError: true };
|
||||
}
|
||||
|
||||
// Strip .md suffix so callers can pass either form; re-add once
|
||||
const baseName = rawName.replace(/\.md$/i, '');
|
||||
|
||||
if (baseName.length === 0 || baseName.length > 128) {
|
||||
return { output: 'ReadUserTemplate: "name" must be 1–128 characters', isError: true };
|
||||
}
|
||||
if (!TEMPLATE_NAME_RE.test(baseName)) {
|
||||
return {
|
||||
output:
|
||||
'ReadUserTemplate: "name" must contain only alphanumeric characters, dashes, underscores, or dots',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
let templatePath: string;
|
||||
try {
|
||||
templatePath = resolveUserSubdir(folderRoot, ctx.userId, 'templates', `${baseName}.md`);
|
||||
} catch (err) {
|
||||
return { output: `ReadUserTemplate: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
return { output: `ReadUserTemplate: template "${baseName}" not found`, isError: true };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(templatePath, 'utf-8');
|
||||
} catch (err) {
|
||||
return { output: `ReadUserTemplate: failed to read template: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
// Best-effort frontmatter parse (not required for templates)
|
||||
let body = raw;
|
||||
const fmLines: string[] = [];
|
||||
try {
|
||||
const parsed = matter(raw);
|
||||
const data = parsed.data as Record<string, unknown>;
|
||||
if (Object.keys(data).length > 0) {
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
fmLines.push(`${k}: ${JSON.stringify(v)}`);
|
||||
}
|
||||
}
|
||||
// Normalize leading newline from gray-matter
|
||||
body = parsed.content.startsWith('\n') ? parsed.content.slice(1) : parsed.content;
|
||||
} catch {
|
||||
// If parse fails, fall back to raw content as body
|
||||
body = raw;
|
||||
}
|
||||
|
||||
const parts: string[] = [`# Template: ${baseName}`];
|
||||
if (fmLines.length > 0) {
|
||||
parts.push('');
|
||||
parts.push('## Frontmatter');
|
||||
parts.push(...fmLines);
|
||||
}
|
||||
parts.push('');
|
||||
parts.push('## Body');
|
||||
parts.push(body.trim());
|
||||
|
||||
return { output: parts.join('\n'), isError: false };
|
||||
}
|
||||
|
||||
// ── RenderUserTemplate implementation ───────────────────────────────────────
|
||||
|
||||
async function executeRenderUserTemplate(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'RenderUserTemplate requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
const rawName = input['name'];
|
||||
if (typeof rawName !== 'string' || !rawName.trim()) {
|
||||
return { output: 'RenderUserTemplate: "name" parameter is required', isError: true };
|
||||
}
|
||||
|
||||
const baseName = rawName.replace(/\.md$/i, '');
|
||||
if (baseName.length === 0 || baseName.length > 128) {
|
||||
return { output: 'RenderUserTemplate: "name" must be 1–128 characters', isError: true };
|
||||
}
|
||||
if (!TEMPLATE_NAME_RE.test(baseName)) {
|
||||
return {
|
||||
output:
|
||||
'RenderUserTemplate: "name" must contain only alphanumeric characters, dashes, underscores, or dots',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
let templatePath: string;
|
||||
try {
|
||||
templatePath = resolveUserSubdir(folderRoot, ctx.userId, 'templates', `${baseName}.md`);
|
||||
} catch (err) {
|
||||
return { output: `RenderUserTemplate: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
return { output: `RenderUserTemplate: template "${baseName}" not found`, isError: true };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(templatePath, 'utf-8');
|
||||
} catch (err) {
|
||||
return {
|
||||
output: `RenderUserTemplate: failed to read template: ${(err as Error).message}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse frontmatter via parseScript — shares the params schema with scripts.
|
||||
// Templates without frontmatter render as a pass-through (no param substitution).
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseScript(raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
output: `RenderUserTemplate: invalid frontmatter: ${(err as Error).message}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const rawParams = (input['params'] as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
try {
|
||||
const rendered = renderTemplate(parsed.body, parsed.frontmatter.params, rawParams);
|
||||
return { output: rendered, isError: false };
|
||||
} catch (err) {
|
||||
return { output: `RenderUserTemplate: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListUserAssets implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeListUserAssets(
|
||||
@@ -565,39 +326,6 @@ async function executeListUserAssets(
|
||||
|
||||
const lines: string[] = [`User folder for ${ctx.userId}:`];
|
||||
|
||||
// Scripts (plain Node runtime)
|
||||
if (kind === 'scripts' || kind === 'all') {
|
||||
const scriptsDir = join(userDir, 'scripts');
|
||||
const scriptEntries: string[] = [];
|
||||
|
||||
if (existsSync(scriptsDir)) {
|
||||
const files = readdirSync(scriptsDir)
|
||||
.filter((f) => extname(f) === '.js')
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const scriptPath = join(scriptsDir, file);
|
||||
try {
|
||||
const source = readFileSync(scriptPath, 'utf-8');
|
||||
const parsed = parseScript(source);
|
||||
const { description, params } = parsed.frontmatter;
|
||||
const paramStr = params.map((p) => `${p.name}:${p.type}`).join(', ');
|
||||
const entry = ` - ${file}: "${description}" — params: [${paramStr}]`;
|
||||
scriptEntries.push(entry);
|
||||
} catch (err) {
|
||||
scriptEntries.push(` - ${file}: (parse error: ${(err as Error).message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`Scripts (${scriptEntries.length}):`);
|
||||
if (scriptEntries.length === 0) {
|
||||
lines.push(' (none)');
|
||||
} else {
|
||||
lines.push(...scriptEntries);
|
||||
}
|
||||
}
|
||||
|
||||
// Browser Macros (Playwright runtime)
|
||||
if (kind === 'browser-macros' || kind === 'all') {
|
||||
const macrosDir = join(userDir, 'browser-macros');
|
||||
@@ -634,35 +362,6 @@ async function executeListUserAssets(
|
||||
}
|
||||
}
|
||||
|
||||
// Templates
|
||||
if (kind === 'templates' || kind === 'all') {
|
||||
const templatesDir = join(userDir, 'templates');
|
||||
const templateEntries: string[] = [];
|
||||
|
||||
if (existsSync(templatesDir)) {
|
||||
const files = readdirSync(templatesDir).sort();
|
||||
for (const file of files) {
|
||||
try {
|
||||
const stat = statSync(join(templatesDir, file));
|
||||
if (stat.isFile()) {
|
||||
templateEntries.push(
|
||||
` - ${file} (${stat.size} bytes, ${stat.mtime.toISOString()})`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`Templates (${templateEntries.length}):`);
|
||||
if (templateEntries.length === 0) {
|
||||
lines.push(' (none)');
|
||||
} else {
|
||||
lines.push(...templateEntries);
|
||||
}
|
||||
}
|
||||
|
||||
// Recordings
|
||||
if (kind === 'recordings' || kind === 'all') {
|
||||
const recordingsDir = join(userDir, 'recordings');
|
||||
@@ -710,8 +409,7 @@ async function executeRunUserScript(
|
||||
if (cfg.tools?.userScriptsEnabled !== true) {
|
||||
return {
|
||||
output:
|
||||
'User scripts are disabled (set tools.user_scripts_enabled: true in config.yaml). ' +
|
||||
'Note: plain-runtime scripts run under Node --permission, but browser-macros do not.',
|
||||
'User browser-macros are disabled (set tools.user_scripts_enabled: true in config.yaml).',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -740,11 +438,17 @@ async function executeRunUserScript(
|
||||
|
||||
const params = (input['params'] as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
const rawKind = input['kind'];
|
||||
const kind: 'script' | 'browser-macro' | undefined =
|
||||
rawKind === 'browser-macro' ? 'browser-macro' :
|
||||
rawKind === 'script' ? 'script' :
|
||||
undefined;
|
||||
// Plain-Node scripts were retired (2026-06): everything is a browser-macro.
|
||||
// Reject an explicit kind="script" with a pointer to the replacement.
|
||||
if (input['kind'] === 'script') {
|
||||
return {
|
||||
output:
|
||||
'RunUserScript: plain-Node scripts/ were retired. Run ad-hoc code with the Bash tool, ' +
|
||||
'or keep reusable procedures in Skills. This tool now runs browser-macros only.',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const kind = 'browser-macro' as const;
|
||||
|
||||
const scriptBaseName = (rawName.endsWith('.js') ? rawName : `${rawName}.js`).replace(/\.js$/, '');
|
||||
|
||||
@@ -824,7 +528,6 @@ async function executeWriteUserScript(
|
||||
|
||||
const name = input['name'];
|
||||
const content = input['content'];
|
||||
const kind = (input['kind'] as string | undefined) ?? 'script';
|
||||
const overwrite = input['overwrite'] === true;
|
||||
|
||||
if (typeof name !== 'string' || !name) {
|
||||
@@ -833,9 +536,16 @@ async function executeWriteUserScript(
|
||||
if (typeof content !== 'string') {
|
||||
return { output: 'WriteUserScript: "content" must be a string', isError: true };
|
||||
}
|
||||
if (kind !== 'script' && kind !== 'browser-macro') {
|
||||
return { output: 'WriteUserScript: "kind" must be "script" or "browser-macro"', isError: true };
|
||||
// Plain-Node scripts were retired (2026-06): everything is a browser-macro.
|
||||
if (input['kind'] === 'script') {
|
||||
return {
|
||||
output:
|
||||
'WriteUserScript: plain-Node scripts/ were retired. Keep reusable procedures in Skills ' +
|
||||
'and run ad-hoc code with the Bash tool. This tool now writes browser-macros only.',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const kind = 'browser-macro' as const;
|
||||
|
||||
// Strip optional .js suffix; validate the base name as a slug
|
||||
const baseName = name.replace(/\.js$/i, '');
|
||||
@@ -858,14 +568,14 @@ async function executeWriteUserScript(
|
||||
return {
|
||||
output:
|
||||
'WriteUserScript: content must define a `main` function ' +
|
||||
'(e.g. async function main({params}) {…} or module.exports = async function main(…)). ' +
|
||||
'(e.g. async function main({context, params}) {…} or module.exports = async function main(…)). ' +
|
||||
'See ReadToolDoc({ name: "WriteUserScript" }) for examples.',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const userFolderRoot = getUserFolderRoot();
|
||||
const subdir = kind === 'script' ? 'scripts' : 'browser-macros';
|
||||
const subdir = 'browser-macros';
|
||||
|
||||
let targetPath: string;
|
||||
try {
|
||||
@@ -907,88 +617,6 @@ async function executeWriteUserScript(
|
||||
};
|
||||
}
|
||||
|
||||
// ── WriteUserTemplate implementation ─────────────────────────────────────────
|
||||
|
||||
/** Slug regex for templates: same rules as scripts but allow dots too (e.g. v2.0). */
|
||||
const TEMPLATE_SLUG_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
async function executeWriteUserTemplate(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'WriteUserTemplate: requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
const name = input['name'];
|
||||
const content = input['content'];
|
||||
const overwrite = input['overwrite'] === true;
|
||||
|
||||
if (typeof name !== 'string' || !name) {
|
||||
return { output: 'WriteUserTemplate: "name" parameter is required', isError: true };
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
return { output: 'WriteUserTemplate: "content" must be a string', isError: true };
|
||||
}
|
||||
|
||||
// Strip optional .md suffix; validate as a slug
|
||||
const baseName = name.replace(/\.md$/i, '');
|
||||
if (!TEMPLATE_SLUG_RE.test(baseName)) {
|
||||
return {
|
||||
output:
|
||||
'WriteUserTemplate: "name" must contain only alphanumeric characters, dashes, or underscores (no spaces, no slashes)',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Size limit: 128 KB
|
||||
const MAX_BYTES = 128 * 1024;
|
||||
if (Buffer.byteLength(content, 'utf-8') > MAX_BYTES) {
|
||||
return { output: `WriteUserTemplate: content exceeds ${MAX_BYTES} bytes`, isError: true };
|
||||
}
|
||||
|
||||
const userFolderRoot = getUserFolderRoot();
|
||||
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = resolveUserSubdir(userFolderRoot, ctx.userId, 'templates', `${baseName}.md`);
|
||||
} catch (err) {
|
||||
return { output: `WriteUserTemplate: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
const targetDir = join(targetPath, '..');
|
||||
try {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return { output: `WriteUserTemplate: failed to create templates/: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
if (existsSync(targetPath) && !overwrite) {
|
||||
return {
|
||||
output: `WriteUserTemplate: templates/${baseName}.md already exists. Pass overwrite: true to replace.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Atomic write
|
||||
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
|
||||
try {
|
||||
writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
|
||||
renameSync(tmpPath, targetPath);
|
||||
} catch (err) {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
return { output: `WriteUserTemplate: write failed: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
const bytes = Buffer.byteLength(content, 'utf-8');
|
||||
_deps?.auditLog?.('user_template_written', { userId: ctx.userId, filename: `${baseName}.md`, bytes }, ctx.taskId ?? null);
|
||||
|
||||
return {
|
||||
output: `WriteUserTemplate: wrote templates/${baseName}.md (${bytes} bytes)`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeTool(
|
||||
@@ -998,11 +626,8 @@ export async function executeTool(
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx);
|
||||
if (name === 'ReadUserMemory') return executeReadUserMemory(input, ctx);
|
||||
if (name === 'ReadUserTemplate') return executeReadUserTemplate(input, ctx);
|
||||
if (name === 'RenderUserTemplate') return executeRenderUserTemplate(input, ctx);
|
||||
if (name === 'ListUserAssets') return executeListUserAssets(input, ctx);
|
||||
if (name === 'RunUserScript') return executeRunUserScript(input, ctx);
|
||||
if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx);
|
||||
if (name === 'WriteUserTemplate') return executeWriteUserTemplate(input, ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user