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;