sync: update from private repo (ce93095)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-10 03:52:37 +00:00
parent eb35e32f7a
commit 9f8958c4a2
27 changed files with 261 additions and 1582 deletions
-3
View File
@@ -55,10 +55,7 @@ const META_TOOLS = new Set<string>([
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'ReadUserTemplate',
'RenderUserTemplate',
'WriteUserScript',
'WriteUserTemplate',
'Brainstorm',
'ReadAppDoc',
'ListAppDocs',
+38 -38
View File
@@ -80,12 +80,12 @@ describe('User Folder API', () => {
describe('GET /folder/list', () => {
it('returns files in the requested subdir', async () => {
// Write 2 files directly into the subdir
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'hello.js'), 'console.log("hi")');
writeFileSync(join(scriptsDir, 'world.ts'), 'export {}');
const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
const res = await request(app).get('/api/users/me/folder/list?subdir=browser-macros');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name).sort();
expect(names).toEqual(['hello.js', 'world.ts']);
@@ -101,12 +101,12 @@ describe('User Folder API', () => {
});
it('does not return hidden files (starting with .)', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'visible.js'), 'ok');
writeFileSync(join(scriptsDir, '.hidden'), 'secret');
const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
const res = await request(app).get('/api/users/me/folder/list?subdir=browser-macros');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
expect(names).toContain('visible.js');
@@ -115,7 +115,7 @@ describe('User Folder API', () => {
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts');
const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=browser-macros');
expect(res.status).toBe(401);
});
@@ -370,41 +370,41 @@ describe('User Folder API', () => {
describe('GET /folder/file', () => {
it('returns file contents as text', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'test.js'), 'console.log("hello")');
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=test.js');
const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=test.js');
expect(res.status).toBe(200);
expect(res.text).toBe('console.log("hello")');
});
it('returns 400 for path traversal attempt', async () => {
const res = await request(app).get(
'/api/users/me/folder/file?subdir=scripts&path=../../etc/passwd',
'/api/users/me/folder/file?subdir=browser-macros&path=../../etc/passwd',
);
expect(res.status).toBe(400);
});
it('returns 404 for a missing file', async () => {
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=nope.js');
const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=nope.js');
expect(res.status).toBe(404);
});
it('returns 413 for a file larger than 1 MB', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
// Write a 1.1 MB file
const big = Buffer.alloc(1024 * 1024 + 100, 'x');
writeFileSync(join(scriptsDir, 'big.txt'), big);
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=big.txt');
const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=big.txt');
expect(res.status).toBe(413);
});
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).get('/api/users/me/folder/file?subdir=scripts&path=x.js');
const res = await request(unauthApp).get('/api/users/me/folder/file?subdir=browser-macros&path=x.js');
expect(res.status).toBe(401);
});
});
@@ -417,7 +417,7 @@ describe('User Folder API', () => {
it('writes the file (verifiable via direct fs read)', async () => {
const content = 'export const x = 1;';
const res = await request(app)
.put('/api/users/me/folder/file?subdir=scripts&path=new.js')
.put('/api/users/me/folder/file?subdir=browser-macros&path=new.js')
.set('Content-Type', 'text/plain')
.send(content);
@@ -426,18 +426,18 @@ describe('User Folder API', () => {
expect(typeof res.body.size).toBe('number');
expect(typeof res.body.mtime).toBe('string');
const written = readFileSync(join(tmpRoot, USER_A, 'scripts', 'new.js'), 'utf-8');
const written = readFileSync(join(tmpRoot, USER_A, 'browser-macros', 'new.js'), 'utf-8');
expect(written).toBe(content);
});
it('is atomic: a follow-up GET sees the new content', async () => {
const content = 'const y = 42;';
await request(app)
.put('/api/users/me/folder/file?subdir=scripts&path=atomic.js')
.put('/api/users/me/folder/file?subdir=browser-macros&path=atomic.js')
.set('Content-Type', 'text/plain')
.send(content);
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=atomic.js');
const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=atomic.js');
expect(res.status).toBe(200);
expect(res.text).toBe(content);
});
@@ -445,7 +445,7 @@ describe('User Folder API', () => {
it('returns 413 when body exceeds 1 MB', async () => {
const big = Buffer.alloc(1024 * 1024 + 100, 'a').toString();
const res = await request(app)
.put('/api/users/me/folder/file?subdir=scripts&path=big.js')
.put('/api/users/me/folder/file?subdir=browser-macros&path=big.js')
.set('Content-Type', 'text/plain')
.send(big);
expect(res.status).toBe(413);
@@ -463,7 +463,7 @@ describe('User Folder API', () => {
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp)
.put('/api/users/me/folder/file?subdir=scripts&path=x.js')
.put('/api/users/me/folder/file?subdir=browser-macros&path=x.js')
.set('Content-Type', 'text/plain')
.send('hi');
expect(res.status).toBe(401);
@@ -476,12 +476,12 @@ describe('User Folder API', () => {
describe('DELETE /folder/file', () => {
it('moves the file into trash/ with a timestamp prefix', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'to-delete.js'), 'bye');
const res = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=to-delete.js',
'/api/users/me/folder/file?subdir=browser-macros&path=to-delete.js',
);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
@@ -500,7 +500,7 @@ describe('User Folder API', () => {
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).delete(
'/api/users/me/folder/file?subdir=scripts&path=x.js',
'/api/users/me/folder/file?subdir=browser-macros&path=x.js',
);
expect(res.status).toBe(401);
});
@@ -514,19 +514,19 @@ describe('User Folder API', () => {
it('returns 404 when DELETE targets a missing file', async () => {
const res = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=ghost.js',
'/api/users/me/folder/file?subdir=browser-macros&path=ghost.js',
);
expect(res.status).toBe(404);
});
it('handles two same-name deletes in quick succession without data loss', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
// First file
writeFileSync(join(scriptsDir, 'dup.js'), 'first');
const res1 = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=dup.js',
'/api/users/me/folder/file?subdir=browser-macros&path=dup.js',
);
expect(res1.status).toBe(200);
const trashedAs1 = res1.body.trashedAs as string;
@@ -534,7 +534,7 @@ describe('User Folder API', () => {
// Second file with same name
writeFileSync(join(scriptsDir, 'dup.js'), 'second');
const res2 = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=dup.js',
'/api/users/me/folder/file?subdir=browser-macros&path=dup.js',
);
expect(res2.status).toBe(200);
const trashedAs2 = res2.body.trashedAs as string;
@@ -556,13 +556,13 @@ describe('User Folder API', () => {
describe('Cross-user isolation', () => {
it('user A cannot read files belonging to user B', async () => {
// Write a file under user B's folder directly
const bScriptsDir = join(tmpRoot, USER_B, 'scripts');
const bScriptsDir = join(tmpRoot, USER_B, 'browser-macros');
mkdirSync(bScriptsDir, { recursive: true });
writeFileSync(join(bScriptsDir, 'secret.js'), 'b-secret');
// app is authed as USER_A; try to reach USER_B's file via traversal
const res = await request(app).get(
`/api/users/me/folder/file?subdir=scripts&path=../../${USER_B}/scripts/secret.js`,
`/api/users/me/folder/file?subdir=browser-macros&path=../../${USER_B}/scripts/secret.js`,
);
// Must be 400 (traversal blocked) — NOT 200
expect(res.status).toBe(400);
@@ -570,14 +570,14 @@ describe('User Folder API', () => {
it('user A list only sees their own files, not user B files', async () => {
// Create scripts for both users
const aDir = join(tmpRoot, USER_A, 'scripts');
const bDir = join(tmpRoot, USER_B, 'scripts');
const aDir = join(tmpRoot, USER_A, 'browser-macros');
const bDir = join(tmpRoot, USER_B, 'browser-macros');
mkdirSync(aDir, { recursive: true });
mkdirSync(bDir, { recursive: true });
writeFileSync(join(aDir, 'a-only.js'), 'a');
writeFileSync(join(bDir, 'b-only.js'), 'b');
const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
const res = await request(app).get('/api/users/me/folder/list?subdir=browser-macros');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
expect(names).toContain('a-only.js');
@@ -728,7 +728,7 @@ describe('User Folder API', () => {
describe('POST /scripts/:name/run', () => {
it('runs a script that returns 42', async () => {
const scriptDir = join(tmpRoot, USER_A, 'scripts');
const scriptDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptDir, { recursive: true });
writeFileSync(join(scriptDir, 'simple.js'), SIMPLE_SCRIPT_BODY);
@@ -750,7 +750,7 @@ describe('User Folder API', () => {
});
it('returns 500 with "param" in error when params are bad', async () => {
const scriptDir = join(tmpRoot, USER_A, 'scripts');
const scriptDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptDir, { recursive: true });
// Script with a declared param of type string
const scriptWithParam = `\
@@ -773,7 +773,7 @@ module.exports = async function main({ params }) { return params.username; };
});
it('clamps timeoutMs to 5 minutes max', async () => {
const scriptDir = join(tmpRoot, USER_A, 'scripts');
const scriptDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptDir, { recursive: true });
writeFileSync(join(scriptDir, 'fast.js'), SIMPLE_SCRIPT_BODY);
@@ -1055,18 +1055,18 @@ module.exports = async function main({ params }) { return params.username; };
it('returns 401 when authActive=true (default) and no user', async () => {
// makeUnauthApp uses the default (authActive not passed → defaults to true)
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts');
const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=browser-macros');
expect(res.status).toBe(401);
expect(res.body.error).toMatch(/Unauthenticated/i);
});
it('falls back to synthetic local user when authActive=false', async () => {
const noAuthApp = makeNoAuthModeApp(tmpRoot);
// Pre-create the 'local' user scripts dir so list returns 200 rather than 500
const localScriptsDir = join(tmpRoot, 'local', 'scripts');
mkdirSync(localScriptsDir, { recursive: true });
// Pre-create the 'local' user macros dir so list returns 200 rather than 500
const localMacrosDir = join(tmpRoot, 'local', 'browser-macros');
mkdirSync(localMacrosDir, { recursive: true });
const res = await request(noAuthApp).get('/api/users/me/folder/list?subdir=scripts');
const res = await request(noAuthApp).get('/api/users/me/folder/list?subdir=browser-macros');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.files)).toBe(true);
});
+8 -21
View File
@@ -57,7 +57,7 @@ function isUserSubdir(s: string): s is UserSubdir {
// Subdirs that users may write to / delete from. 'trash' is system-managed.
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers
// then delegate immediately to NotesService rather than the generic file writer.
const WRITABLE_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'notes'] as const;
const WRITABLE_SUBDIRS = ['browser-macros', 'recordings', 'notes'] as const;
type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
function isWritableSubdir(s: string): s is WritableSubdir {
return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
@@ -639,31 +639,18 @@ export function createUserFolderApi(deps: Deps): Router {
return;
}
const { params, timeoutMs, kind } = ((req.body as Record<string, unknown>) ?? {}) as {
const { params, timeoutMs } = ((req.body as Record<string, unknown>) ?? {}) as {
params?: Record<string, unknown>;
timeoutMs?: number;
kind?: string;
};
// Resolve script path depending on kind
// Plain-Node scripts/ were retired (2026-06) — only browser-macros run here.
let scriptPath: string | null = null;
let resolvedRuntime: 'plain' | 'playwright' = 'plain';
if (!kind || kind === 'script') {
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'scripts', scriptFileName);
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'plain'; }
} catch { /* invalid path */ }
}
if (!scriptPath && (!kind || kind === 'browser-macro')) {
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'playwright'; }
} catch { /* invalid path */ }
}
if (!scriptPath && kind === 'script') {
// explicit kind but no match — keep null to hit 404 below
}
const resolvedRuntime = 'playwright' as const;
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
if (existsSync(candidate)) scriptPath = candidate;
} catch { /* invalid path */ }
if (scriptPath === null) {
res.status(404).json({ error: `Script not found: ${scriptFileName}` });
return;
+4 -5
View File
@@ -71,12 +71,11 @@ export interface ToolsConfig {
*/
taskUploadMaxSizeMb?: number;
/**
* Allow RunUserScript to execute user-authored scripts.
* Allow RunUserScript to execute user-authored browser-macros.
* Default: false (opt-in required).
* Plain-runtime scripts now run under Node's Permissions Model
* (--permission), which blocks child_process, worker threads, and FS access
* outside tmpdir. browser-macros still run with full Node.js capabilities
* because Playwright needs them — only enable for trusted users.
* Browser-macros run with full Node.js capabilities because Playwright
* needs them — only enable for trusted users.
* (Plain-Node scripts/ were retired in 2026-06; Skills + Bash replace them.)
*/
userScriptsEnabled?: boolean;
/**
+46 -470
View File
@@ -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
View File
@@ -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 1128 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 1128 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;
}
+2 -2
View File
@@ -75,8 +75,8 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
// mission.ts
'MissionUpdate',
// user-folder.ts
'ListUserAssets', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate',
'RunUserScript', 'UpdateUserMemory', 'WriteUserScript', 'WriteUserTemplate',
'ListUserAssets', 'ReadUserMemory',
'RunUserScript', 'UpdateUserMemory', 'WriteUserScript',
// brainstorm.ts
'Brainstorm',
// app-docs.ts
+3 -3
View File
@@ -363,12 +363,12 @@ describe('Scheduler.executeScheduledTask: task_kind="script"', () => {
});
function writeScript(userId: string, name: string, source: string): void {
const dir = join(userFolderRoot, userId, 'scripts');
const dir = join(userFolderRoot, userId, 'browser-macros');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), source, 'utf-8');
}
it('runs a plain script and marks the job succeeded with stdout saved to workspace', async () => {
it('runs a browser-macro and marks the job succeeded with stdout saved to workspace', async () => {
const owner = repo.createUser({ email: '[email protected]', name: 'eve', role: 'user', status: 'active' });
writeScript(owner.id, 'hello.js', `---
params:
@@ -473,7 +473,7 @@ module.exports = main;
// (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask
// used to throw "requires an owner_id", so no-auth scheduled scripts could
// never run. Now it falls back to the 'local' namespace (same as the
// RunUserScript tool), resolving the script from data/users/local/scripts/.
// RunUserScript tool), resolving the macro from data/users/local/browser-macros/.
writeScript('local', 'noauth.js', `async function main() {
return { ran: 'no-auth-local' };
}
+1 -1
View File
@@ -303,7 +303,7 @@ export class Scheduler {
throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`);
}
// No-auth mode stores scheduled tasks with ownerId=null (scheduled-tasks-api
// has no authenticated user). Scripts are per-user (data/users/{id}/scripts/),
// has no authenticated user). Macros are per-user (data/users/{id}/browser-macros/),
// so resolve to the same 'local' namespace the RunUserScript tool uses in
// no-auth (ctx.userId='local'). In auth mode item.ownerId is always set, so
// scriptOwner === item.ownerId and behaviour is unchanged.
+5 -5
View File
@@ -11,7 +11,7 @@ describe('user-folder/paths', () => {
it('creates the standard subdirs on first ensure', () => {
ensureUserFolder(root, 'user-abc');
for (const sub of ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets']) {
for (const sub of ['browser-macros', 'recordings', 'trash', 'memory', 'pets', 'notes']) {
expect(existsSync(join(root, 'user-abc', sub))).toBe(true);
}
});
@@ -35,17 +35,17 @@ describe('user-folder/paths', () => {
});
it('resolves subdir paths under the owner root', () => {
const p = resolveUserSubdir(root, 'user-abc', 'scripts', 'foo.js');
expect(p).toBe(join(root, 'user-abc', 'scripts', 'foo.js'));
const p = resolveUserSubdir(root, 'user-abc', 'browser-macros', 'foo.js');
expect(p).toBe(join(root, 'user-abc', 'browser-macros', 'foo.js'));
});
it('rejects path traversal in the relative segment', () => {
expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', '../../etc/passwd'))
expect(() => resolveUserSubdir(root, 'user-abc', 'browser-macros', '../../etc/passwd'))
.toThrow(/outside owner folder/);
});
it('rejects empty relPath', () => {
expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', ''))
expect(() => resolveUserSubdir(root, 'user-abc', 'browser-macros', ''))
.toThrow(/relPath must not be empty/);
});
+4 -1
View File
@@ -6,7 +6,10 @@ export const LOCAL_SYSTEM_OWNER_ID = 'local';
const USER_AGENTS_MAX_BYTES = 64 * 1024;
export const USER_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const;
// 'scripts' and 'templates' were retired in 2026-06 (superseded by Skills +
// the Bash tool). Existing files stay on disk but are no longer created,
// listed, or resolvable through the user-folder API / tools.
export const USER_SUBDIRS = ['browser-macros', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const;
export type UserSubdir = typeof USER_SUBDIRS[number];
export function userRoot(rootDir: string, ownerId: string): string {
+24 -38
View File
@@ -1,17 +1,21 @@
/**
* script-orchestrator.ts
*
* Shared "resolve a user script by name + run it" helper. Used by both:
* Shared "resolve a browser-macro by name + run it" helper. Used by both:
* - The LLM-facing RunUserScript tool (engine/tools/user-folder.ts).
* - The scheduler's script kind (scheduler.ts), so a periodic script run
* - The scheduler's script kind (scheduler.ts), so a periodic macro run
* does not need to spin up an LLM agent loop.
*
* Note: plain-Node `scripts/` and `templates/` were retired in 2026-06
* (superseded by Skills + the Bash tool). The only user-script runtime left
* is Playwright browser-macros.
*
* Responsibilities:
* - Path resolution under data/users/{userId}/{scripts,browser-macros}/ with
* - Path resolution under data/users/{userId}/browser-macros/ with
* traversal protection (delegated to resolveUserSubdir).
* - Frontmatter parsing for browser-macros to find session_profile_id.
* - Frontmatter parsing to find session_profile_id.
* - Decrypting + loading Playwright storageState for the owning user.
* - Calling runUserScript() with the right runtime.
* - Calling runUserScript() with the playwright runtime.
*
* Intentionally NOT responsible for:
* - Config gating (tools.user_scripts_enabled) — callers decide.
@@ -27,9 +31,9 @@ import { runUserScript } from './script-runner.js';
import { loadSessionStateForUser } from './session-loader.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
export type ScriptKind = 'script' | 'browser-macro';
export type ScriptSubdir = 'scripts' | 'browser-macros';
export type ScriptRuntime = 'plain' | 'playwright';
export type ScriptKind = 'browser-macro';
export type ScriptSubdir = 'browser-macros';
export type ScriptRuntime = 'playwright';
export interface ResolveScriptResult {
scriptPath: string;
@@ -37,49 +41,31 @@ export interface ResolveScriptResult {
runtime: ScriptRuntime;
}
/**
* Resolve a script name to its on-disk path. When kind is omitted, scripts/
* is searched first, then browser-macros/.
*/
/** Resolve a macro name to its on-disk path under browser-macros/. */
export function resolveScriptForKind(
rootDir: string,
userId: string,
scriptName: string,
kind: ScriptKind | undefined,
_kind?: ScriptKind,
): ResolveScriptResult | { error: string } {
const tryOne = (sd: ScriptSubdir): string | null => {
try {
const p = resolveUserSubdir(rootDir, userId, sd, scriptName);
return existsSync(p) ? p : null;
} catch {
return null;
}
};
if (kind === 'script') {
const p = tryOne('scripts');
if (!p) return { error: `script not found: scripts/${basename(scriptName)}` };
return { scriptPath: p, subdir: 'scripts', runtime: 'plain' };
let p: string | null;
try {
const full = resolveUserSubdir(rootDir, userId, 'browser-macros', scriptName);
p = existsSync(full) ? full : null;
} catch {
p = null;
}
if (kind === 'browser-macro') {
const p = tryOne('browser-macros');
if (!p) return { error: `browser-macro not found: browser-macros/${basename(scriptName)}` };
return { scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' };
}
const sp = tryOne('scripts');
if (sp) return { scriptPath: sp, subdir: 'scripts', runtime: 'plain' };
const bp = tryOne('browser-macros');
if (bp) return { scriptPath: bp, subdir: 'browser-macros', runtime: 'playwright' };
return { error: `script not found: ${scriptName} (searched scripts/ and browser-macros/)` };
if (!p) return { error: `browser-macro not found: browser-macros/${basename(scriptName)}` };
return { scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' };
}
export interface ResolveAndRunOptions {
rootDir: string;
userId: string;
/** Script name with or without `.js` extension. */
/** Macro name with or without `.js` extension. */
name: string;
params: Record<string, unknown>;
/** If omitted, scripts/ is tried first, then browser-macros/. */
/** Kept for call-site compatibility; the only kind is 'browser-macro'. */
kind?: ScriptKind;
/** Required only when the resolved script is a browser-macro that declares session_profile_id. */
sessRepo?: BrowserSessionRepo;
-32
View File
@@ -1,32 +0,0 @@
/**
* template-renderer.ts
*
* Simple {{var}} substitution for user templates. Intentionally minimal:
* - No conditionals, no loops, no helpers. If those become needed,
* graduate to Handlebars in a follow-up.
* - Unknown placeholders (var not declared in frontmatter.params) are
* left literal — so README-style templates with prose like "use {{x}}"
* don't blow up when there's no x param.
*
* Param semantics (type-check + defaults) are shared with scripts via
* validateAndApplyDefaults from script-runner.ts; templates use the same
* frontmatter.params schema as scripts/browser-macros.
*/
import type { ParamSpec } from './frontmatter.js';
import { validateAndApplyDefaults } from './script-runner.js';
/**
* Replaces {{name}} with the corresponding param value, but only for params
* that appear in `declared` (the validated set). Unknown {{xxx}} stays literal.
*/
export function renderTemplate(
body: string,
paramSpec: ParamSpec[],
rawParams: Record<string, unknown>,
): string {
const resolved = validateAndApplyDefaults(paramSpec, rawParams);
return body.replace(/\{\{(\w+)\}\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(resolved, name) ? String(resolved[name]) : match,
);
}