This commit is contained in:
@@ -55,10 +55,7 @@ const META_TOOLS = new Set<string>([
|
||||
'RunUserScript',
|
||||
'UpdateUserMemory',
|
||||
'ReadUserMemory',
|
||||
'ReadUserTemplate',
|
||||
'RenderUserTemplate',
|
||||
'WriteUserScript',
|
||||
'WriteUserTemplate',
|
||||
'Brainstorm',
|
||||
'ReadAppDoc',
|
||||
'ListAppDocs',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user