feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { parseScript, serializeScript } from './frontmatter.js';
import type { ParsedScript } from './frontmatter.js';
// ── helpers ──────────────────────────────────────────────────────────────────
const VALID_SOURCE = `---
description: "Log into example.com"
params:
- name: date
type: string
description: "ISO date YYYY-MM-DD"
- name: verbose
type: boolean
default: false
session_profile_id: 7
recording_source: "rec-2026-05-09T12-34-56.json"
created_at: "2026-05-09T12:35:01Z"
updated_at: "2026-05-09T12:35:01Z"
---
async function main({ context, params }) {}
module.exports = main;
`;
const VALID_PARSED: ParsedScript = {
frontmatter: {
description: 'Log into example.com',
params: [
{ name: 'date', type: 'string', description: 'ISO date YYYY-MM-DD' },
{ name: 'verbose', type: 'boolean', default: false },
],
sessionProfileId: 7,
recordingSource: 'rec-2026-05-09T12-34-56.json',
createdAt: '2026-05-09T12:35:01Z',
updatedAt: '2026-05-09T12:35:01Z',
},
body: 'async function main({ context, params }) {}\nmodule.exports = main;\n',
};
// ── tests ─────────────────────────────────────────────────────────────────────
describe('user-folder/frontmatter', () => {
// Test 1: valid input produces expected ParsedScript
it('parseScript of a valid input produces the expected ParsedScript', () => {
const result = parseScript(VALID_SOURCE);
expect(result).toEqual(VALID_PARSED);
});
// Test 2: body-only input (no frontmatter)
it('parseScript of body-only input produces { frontmatter: { description: "", params: [] }, body }', () => {
const body = 'async function main() {}\n';
const result = parseScript(body);
expect(result).toEqual({
frontmatter: { description: '', params: [] },
body,
});
});
// Test 3: malformed YAML throws
it('parseScript of malformed YAML throws', () => {
const source = `---
description: [unclosed
---
body
`;
expect(() => parseScript(source)).toThrow();
});
// Test 4: bad params[i].name throws with index in message
it('parseScript of frontmatter with bad params[i].name throws with "params[i].name" in message', () => {
const source = `---
description: test
params:
- name: valid
type: string
- name: "1-invalid"
type: string
---
body
`;
expect(() => parseScript(source)).toThrow(/params\[1\]\.name/);
});
// Test 5: bad params[i].type throws with type info in message
it('parseScript of frontmatter with bad params[i].type throws with "params[i].type" in message', () => {
const source = `---
description: test
params:
- name: myParam
type: integer
---
body
`;
expect(() => parseScript(source)).toThrow(/params\[0\]\.type/);
});
// Test 6: session_profile_id: 0 throws (not positive)
it('parseScript of frontmatter with session_profile_id: 0 throws', () => {
const source = `---
description: test
session_profile_id: 0
---
body
`;
expect(() => parseScript(source)).toThrow(/session_profile_id/);
});
// Test 7: session_profile_id: -1 throws
it('parseScript of frontmatter with session_profile_id: -1 throws', () => {
const source = `---
description: test
session_profile_id: -1
---
body
`;
expect(() => parseScript(source)).toThrow(/session_profile_id/);
});
// Test 8: session_profile_id: 1.5 throws (not integer)
it('parseScript of frontmatter with session_profile_id: 1.5 throws', () => {
const source = `---
description: test
session_profile_id: 1.5
---
body
`;
expect(() => parseScript(source)).toThrow(/session_profile_id/);
});
// Test 8b: session_profile_id: true throws (not a number)
it('throws when session_profile_id is a boolean', () => {
const src = '---\ndescription: test\nparams: []\nsession_profile_id: true\n---\n';
expect(() => parseScript(src)).toThrow(/session_profile_id/);
});
// Test 9: unknown keys warn via logger.warn
it('parseScript accepts unknown keys and calls logger.warn', async () => {
const loggerModule = await import('../logger.js');
const warnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => {});
try {
const source = `---
description: test
unknown_field: "some value"
another_unknown: 42
---
body
`;
const result = parseScript(source);
expect(result.frontmatter.description).toBe('test');
expect(warnSpy).toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
// Test 10: round-trip
it('serializeScript round-trip: parseScript(serializeScript(s)) equals s', () => {
const serialized = serializeScript(VALID_PARSED);
const reparsed = parseScript(serialized);
expect(reparsed).toEqual(VALID_PARSED);
});
// Test 11: empty params array does NOT emit a params: key
it('serializeScript with empty params array does NOT emit a params: key', () => {
const script: ParsedScript = {
frontmatter: { description: 'minimal', params: [] },
body: 'const x = 1;\n',
};
const serialized = serializeScript(script);
expect(serialized).not.toContain('params:');
});
// Test 12: omits undefined optional fields
it('serializeScript omits undefined optional fields', () => {
const script: ParsedScript = {
frontmatter: {
description: 'no optionals',
params: [],
},
body: 'const x = 1;\n',
};
const serialized = serializeScript(script);
expect(serialized).not.toContain('session_profile_id');
expect(serialized).not.toContain('recording_source');
expect(serialized).not.toContain('created_at');
expect(serialized).not.toContain('updated_at');
});
});
+143
View File
@@ -0,0 +1,143 @@
import matter from 'gray-matter';
import { logger } from '../logger.js';
// ── Types ──────────────────────────────────────────────────────────────────────
export interface ParamSpec {
name: string;
type: 'string' | 'number' | 'boolean';
description?: string;
default?: string | number | boolean;
}
export interface ScriptMeta {
description: string;
params: ParamSpec[];
sessionProfileId?: number;
recordingSource?: string;
createdAt?: string;
updatedAt?: string;
}
export interface ParsedScript {
frontmatter: ScriptMeta;
body: string;
}
// ── Constants ─────────────────────────────────────────────────────────────────
const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
const VALID_TYPES = new Set(['string', 'number', 'boolean']);
const KNOWN_KEYS = new Set([
'description', 'params', 'session_profile_id',
'recording_source', 'created_at', 'updated_at',
]);
// ── Parser ────────────────────────────────────────────────────────────────────
export function parseScript(source: string): ParsedScript {
const parsed = matter(source);
const data = parsed.data;
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('invalid frontmatter: expected a YAML mapping');
}
const dataMap = data as Record<string, unknown>;
// gray-matter preserves a leading "\n" in content when there's a blank line after the
// closing "---". Strip it so body is canonical regardless of whether that blank line existed.
const body = parsed.content.startsWith('\n') ? parsed.content.slice(1) : parsed.content;
// No frontmatter block at all — data will be an empty object
if (Object.keys(dataMap).length === 0) {
return { frontmatter: { description: '', params: [] }, body: source };
}
// Warn on unknown keys
for (const key of Object.keys(dataMap)) {
if (!KNOWN_KEYS.has(key)) {
logger.warn(`[frontmatter] unknown key "${key}" in script frontmatter`);
}
}
// description
const description = dataMap['description'] !== undefined ? String(dataMap['description']) : '';
// params
const rawParams = dataMap['params'];
const params: ParamSpec[] = [];
if (rawParams !== undefined) {
if (!Array.isArray(rawParams)) {
throw new Error('invalid frontmatter: params must be an array');
}
for (let i = 0; i < rawParams.length; i++) {
const p = rawParams[i] as Record<string, unknown>;
const name = p['name'];
if (typeof name !== 'string' || !IDENTIFIER_RE.test(name)) {
throw new Error(
`invalid params[${i}].name: must be an identifier, got ${JSON.stringify(name)}`
);
}
const type = p['type'];
if (!VALID_TYPES.has(type as string)) {
throw new Error(
`invalid params[${i}].type: must be string | number | boolean, got ${JSON.stringify(type)}`
);
}
const spec: ParamSpec = { name, type: type as ParamSpec['type'] };
if (p['description'] !== undefined) spec.description = String(p['description']);
if (p['default'] !== undefined) spec.default = p['default'] as string | number | boolean;
params.push(spec);
}
}
// session_profile_id
let sessionProfileId: number | undefined;
if (data['session_profile_id'] !== undefined) {
const raw = data['session_profile_id'];
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) {
throw new Error(
`invalid session_profile_id: must be a positive integer, got ${JSON.stringify(raw)}`
);
}
sessionProfileId = raw;
}
// recording_source
const recordingSource =
dataMap['recording_source'] !== undefined ? String(dataMap['recording_source']) : undefined;
// created_at / updated_at
const createdAt =
dataMap['created_at'] !== undefined ? String(dataMap['created_at']) : undefined;
const updatedAt =
dataMap['updated_at'] !== undefined ? String(dataMap['updated_at']) : undefined;
const frontmatter: ScriptMeta = { description, params };
if (sessionProfileId !== undefined) frontmatter.sessionProfileId = sessionProfileId;
if (recordingSource !== undefined) frontmatter.recordingSource = recordingSource;
if (createdAt !== undefined) frontmatter.createdAt = createdAt;
if (updatedAt !== undefined) frontmatter.updatedAt = updatedAt;
return { frontmatter, body };
}
// ── Serializer ────────────────────────────────────────────────────────────────
export function serializeScript(script: ParsedScript): string {
const { frontmatter, body } = script;
const { description, params, sessionProfileId, recordingSource, createdAt, updatedAt } =
frontmatter;
// Build data in stable key order; omit undefined and empty params
const data: Record<string, unknown> = { description };
if (params && params.length > 0) data['params'] = params;
if (sessionProfileId !== undefined) data['session_profile_id'] = sessionProfileId;
if (recordingSource !== undefined) data['recording_source'] = recordingSource;
if (createdAt !== undefined) data['created_at'] = createdAt;
if (updatedAt !== undefined) data['updated_at'] = updatedAt;
// matter.stringify prepends "---\n<yaml>\n---\n" to the body
return matter.stringify('\n' + body, data);
}
+367
View File
@@ -0,0 +1,367 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
readMemoryIndex,
parseMemoryEntry,
serializeMemoryEntry,
upsertMemoryEntry,
deleteMemoryEntry,
readMemoryEntry,
repairMemoryIndex,
MEMORY_TYPES,
} from './memory.js';
import { ensureUserFolder, userRoot } from './paths.js';
// ── Fixtures ──────────────────────────────────────────────────────────────────
let root: string;
const USER = 'test-user';
function memDir(): string {
return join(userRoot(root, USER), 'memory');
}
function indexPath(): string {
return join(memDir(), 'MEMORY.md');
}
// ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mem-test-'));
ensureUserFolder(root, USER);
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('readMemoryIndex', () => {
it('returns null when MEMORY.md is missing', () => {
expect(readMemoryIndex(root, USER)).toBeNull();
});
it('returns null for an empty MEMORY.md', () => {
writeFileSync(indexPath(), '');
expect(readMemoryIndex(root, USER)).toBeNull();
});
it('returns content when MEMORY.md has entries', () => {
const content = '- [foo](foo.md) — A test entry\n';
writeFileSync(indexPath(), content);
expect(readMemoryIndex(root, USER)).toBe(content);
});
it('truncates files larger than 32 KB and appends a notice', () => {
// 33 KB of 'A'
const big = Buffer.alloc(33 * 1024, 0x41).toString();
writeFileSync(indexPath(), big);
const result = readMemoryIndex(root, USER)!;
expect(result).toContain('[truncated: original was');
expect(result.length).toBeLessThan(33 * 1024 + 100);
});
it('returns null for invalid ownerId', () => {
expect(readMemoryIndex(root, '../escape')).toBeNull();
});
});
describe('parseMemoryEntry / serializeMemoryEntry', () => {
it('roundtrip: serialize then parse returns the same entry', () => {
const entry = {
meta: { name: 'my-fact', description: 'A test fact', type: 'user' as const },
body: 'Some body content here.',
};
const serialized = serializeMemoryEntry(entry);
const parsed = parseMemoryEntry(serialized);
expect(parsed.meta.name).toBe('my-fact');
expect(parsed.meta.description).toBe('A test fact');
expect(parsed.meta.type).toBe('user');
expect(parsed.body.trim()).toBe('Some body content here.');
});
it('rejects an invalid type', () => {
const src = '---\nname: bad\ndescription: test\ntype: invalid\n---\nbody\n';
expect(() => parseMemoryEntry(src)).toThrow(/invalid memory type/);
});
it('accepts all four valid types', () => {
for (const type of MEMORY_TYPES) {
const src = `---\nname: n\ndescription: d\ntype: ${type}\n---\nbody\n`;
const result = parseMemoryEntry(src);
expect(result.meta.type).toBe(type);
}
});
});
describe('upsertMemoryEntry', () => {
it('creates the fact file and updates the index', () => {
const result = upsertMemoryEntry(root, USER, {
name: 'my-fact',
type: 'user',
description: 'A test fact',
body: 'This is the body.',
});
expect(existsSync(result.path)).toBe(true);
expect(result.indexUpdated).toBe(true);
const indexContent = readFileSync(indexPath(), 'utf-8');
expect(indexContent).toContain('- [my-fact](my-fact.md) — A test fact');
});
it('replaces an existing entry (no duplicate index lines)', () => {
upsertMemoryEntry(root, USER, {
name: 'my-fact',
type: 'user',
description: 'First description',
body: 'Body v1.',
});
upsertMemoryEntry(root, USER, {
name: 'my-fact',
type: 'feedback',
description: 'Updated description',
body: 'Body v2.',
});
const indexContent = readFileSync(indexPath(), 'utf-8');
const lines = indexContent.split('\n').filter((l) => l.startsWith('- [my-fact]'));
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('Updated description');
// Verify file content was updated too
const parsed = readMemoryEntry(root, USER, 'my-fact')!;
expect(parsed.meta.type).toBe('feedback');
expect(parsed.body.trim()).toBe('Body v2.');
});
it('preserves existing entries when upserting a new one', () => {
upsertMemoryEntry(root, USER, { name: 'alpha', type: 'user', description: 'Alpha', body: 'A' });
upsertMemoryEntry(root, USER, { name: 'beta', type: 'project', description: 'Beta', body: 'B' });
const indexContent = readFileSync(indexPath(), 'utf-8');
expect(indexContent).toContain('- [alpha](alpha.md) — Alpha');
expect(indexContent).toContain('- [beta](beta.md) — Beta');
});
it('rejects an invalid type', () => {
expect(() =>
upsertMemoryEntry(root, USER, {
name: 'bad',
type: 'invalid' as never,
description: 'X',
body: 'Y',
}),
).toThrow(/invalid memory type/);
});
});
describe('deleteMemoryEntry', () => {
it('moves file to trash and removes index line', () => {
upsertMemoryEntry(root, USER, { name: 'bye', type: 'reference', description: 'Bye', body: 'See ya.' });
const deleted = deleteMemoryEntry(root, USER, 'bye');
expect(deleted).toBe(true);
// File should no longer exist in memory/
expect(existsSync(join(memDir(), 'bye.md'))).toBe(false);
// A file should be in trash/
const trashDir = join(userRoot(root, USER), 'trash');
const trashContents = readdirSync(trashDir);
expect(trashContents.some((f) => f.includes('bye'))).toBe(true);
// Index line should be removed
const indexContent = readFileSync(indexPath(), 'utf-8');
expect(indexContent).not.toContain('bye');
});
it('returns false when the entry does not exist', () => {
expect(deleteMemoryEntry(root, USER, 'nonexistent')).toBe(false);
});
});
describe('readMemoryEntry', () => {
it('returns null when the entry does not exist', () => {
expect(readMemoryEntry(root, USER, 'missing')).toBeNull();
});
it('loads an existing fact file', () => {
upsertMemoryEntry(root, USER, {
name: 'known-fact',
type: 'project',
description: 'A known fact',
body: 'Known body.',
});
const entry = readMemoryEntry(root, USER, 'known-fact')!;
expect(entry).not.toBeNull();
expect(entry.meta.name).toBe('known-fact');
expect(entry.meta.type).toBe('project');
expect(entry.body.trim()).toBe('Known body.');
});
});
describe('traversal-safe name handling', () => {
it('upsertMemoryEntry with a simple name stays inside memory dir', () => {
const result = upsertMemoryEntry(root, USER, {
name: 'safe-name',
type: 'user',
description: 'Safe',
body: 'ok',
});
expect(result.path.startsWith(memDir())).toBe(true);
});
});
describe('assertValidMemoryName enforcement in helpers', () => {
it('upsertMemoryEntry throws on traversal name "../escape"', () => {
expect(() =>
upsertMemoryEntry(root, USER, { name: '../escape', type: 'user', description: 'x', body: 'y' })
).toThrow(/invalid memory name/);
});
it('upsertMemoryEntry throws on name with spaces', () => {
expect(() =>
upsertMemoryEntry(root, USER, { name: 'has spaces', type: 'user', description: 'x', body: 'y' })
).toThrow(/invalid memory name/);
});
it('upsertMemoryEntry throws on name longer than 64 chars', () => {
expect(() =>
upsertMemoryEntry(root, USER, { name: 'a'.repeat(100), type: 'user', description: 'x', body: 'y' })
).toThrow(/invalid memory name/);
});
it('deleteMemoryEntry throws on traversal name "../escape"', () => {
expect(() => deleteMemoryEntry(root, USER, '../escape')).toThrow(/invalid memory name/);
});
it('readMemoryEntry throws on traversal name "../escape"', () => {
expect(() => readMemoryEntry(root, USER, '../escape')).toThrow(/invalid memory name/);
});
it('upsertMemoryEntry throws when description contains newline', () => {
expect(() =>
upsertMemoryEntry(root, USER, {
name: 'valid',
type: 'user',
description: 'line1\nline2',
body: 'ok',
})
).toThrow(/single line/);
});
it('upsertMemoryEntry throws when description contains carriage return', () => {
expect(() =>
upsertMemoryEntry(root, USER, {
name: 'valid',
type: 'user',
description: 'line1\rline2',
body: 'ok',
})
).toThrow(/single line/);
});
});
describe('repairMemoryIndex (self-healing after a crashed upsert/delete)', () => {
it('appends an index line for an orphan fact file', () => {
// Simulate a half-completed upsert: fact file written, index update missed.
const factPath = join(memDir(), 'orphan.md');
writeFileSync(
factPath,
`---\nname: orphan\ndescription: was orphaned by a crash\ntype: project\n---\nbody\n`,
'utf-8',
);
expect(existsSync(indexPath())).toBe(false);
const result = repairMemoryIndex(root, USER);
expect(result.added).toEqual(['orphan']);
expect(result.removed).toEqual([]);
const idx = readFileSync(indexPath(), 'utf-8');
expect(idx).toContain('- [orphan](orphan.md) — was orphaned by a crash');
});
it('removes a dangling index line whose fact file is missing', () => {
// Manually craft a stale index with a line pointing at a non-existent file.
writeFileSync(
indexPath(),
'- [ghost](ghost.md) — referenced but missing\n- [stay](stay.md) — kept\n',
'utf-8',
);
writeFileSync(
join(memDir(), 'stay.md'),
`---\nname: stay\ndescription: kept\ntype: user\n---\nbody\n`,
'utf-8',
);
const result = repairMemoryIndex(root, USER);
expect(result.removed).toEqual(['ghost']);
expect(result.added).toEqual([]);
const idx = readFileSync(indexPath(), 'utf-8');
expect(idx).not.toContain('ghost');
expect(idx).toContain('stay');
});
it('is a no-op when the index is already consistent', () => {
upsertMemoryEntry(root, USER, { name: 'a', type: 'user', description: 'A', body: 'a' });
const before = readFileSync(indexPath(), 'utf-8');
const result = repairMemoryIndex(root, USER);
expect(result).toEqual({ added: [], removed: [] });
expect(readFileSync(indexPath(), 'utf-8')).toBe(before);
});
it('upsertMemoryEntry auto-heals a pre-existing orphan before applying its own write', () => {
// Pre-create an orphan (fact file with no index line).
writeFileSync(
join(memDir(), 'left-over.md'),
`---\nname: left-over\ndescription: from a previous crash\ntype: reference\n---\nbody\n`,
'utf-8',
);
// Subsequent upsert (unrelated entry) should heal first, then add its own line.
upsertMemoryEntry(root, USER, {
name: 'new-one',
type: 'project',
description: 'fresh entry',
body: 'fresh body',
});
const idx = readFileSync(indexPath(), 'utf-8');
expect(idx).toContain('left-over');
expect(idx).toContain('new-one');
});
it('deleteMemoryEntry auto-heals dangling index lines before applying its own change', () => {
// Seed: real entry "alpha", dangling line "ghost".
upsertMemoryEntry(root, USER, { name: 'alpha', type: 'user', description: 'A', body: 'a' });
// Manually prepend a dangling line.
const current = readFileSync(indexPath(), 'utf-8');
writeFileSync(indexPath(), '- [ghost](ghost.md) — gone\n' + current, 'utf-8');
deleteMemoryEntry(root, USER, 'alpha');
const idx = existsSync(indexPath()) ? readFileSync(indexPath(), 'utf-8') : '';
expect(idx).not.toContain('ghost');
expect(idx).not.toContain('alpha');
});
it('ignores files that do not look like memory entries', () => {
// Garbage files inside memory/ — should be left alone.
writeFileSync(join(memDir(), 'scratch.txt'), 'not a memory entry', 'utf-8');
writeFileSync(join(memDir(), '..hidden.md'), 'malformed', 'utf-8');
const result = repairMemoryIndex(root, USER);
expect(result.added).toEqual([]);
expect(result.removed).toEqual([]);
});
});
+551
View File
@@ -0,0 +1,551 @@
/**
* memory.ts — User Memory system helpers
*
* Manages data/users/{userId}/memory/MEMORY.md (index) and individual
* fact files (data/users/{userId}/memory/{name}.md) with YAML frontmatter.
*
* Index format (one line per entry):
* - [{name}](file.md) — {description}
*
* Fact file format:
* ---
* name: short-identifier
* description: one-line hook
* type: user | feedback | project | reference
* ---
* body content
*/
import {
existsSync,
statSync,
readFileSync,
readdirSync,
writeFileSync,
renameSync,
unlinkSync,
mkdirSync,
openSync,
readSync,
closeSync,
} from 'fs';
import { join, dirname } from 'path';
import matter from 'gray-matter';
import { userRoot } from './paths.js';
// ── Constants ─────────────────────────────────────────────────────────────────
const MEMORY_INDEX_MAX_BYTES = 32 * 1024;
const INDEX_FILE = 'MEMORY.md';
// ── Name validation ───────────────────────────────────────────────────────────
export const MEMORY_NAME_RE = /^[a-zA-Z0-9_-]+$/;
/**
* Throws if `name` is not a valid memory entry identifier.
* Valid: 164 chars, alphanumeric / dash / underscore only.
*/
export function assertValidMemoryName(name: string): void {
if (
typeof name !== 'string' ||
name.length === 0 ||
name.length > 64 ||
!MEMORY_NAME_RE.test(name)
) {
throw new Error(`invalid memory name: ${JSON.stringify(name)}`);
}
}
export const MEMORY_TYPES = ['user', 'feedback', 'project', 'reference'] as const;
export type MemoryType = typeof MEMORY_TYPES[number];
// ── Types ─────────────────────────────────────────────────────────────────────
export interface MemoryMeta {
name: string;
description: string;
type: MemoryType;
}
export interface MemoryEntry {
meta: MemoryMeta;
body: string;
}
// ── Internal helpers ──────────────────────────────────────────────────────────
function memoryDir(rootDir: string, ownerId: string): string {
return join(userRoot(rootDir, ownerId), 'memory');
}
function factPath(rootDir: string, ownerId: string, name: string): string {
return join(memoryDir(rootDir, ownerId), `${name}.md`);
}
function trashDir(rootDir: string, ownerId: string): string {
return join(userRoot(rootDir, ownerId), 'trash');
}
/** Atomic write: tmp file + rename. */
function writeAtomic(filePath: string, content: string): void {
const dir = dirname(filePath);
mkdirSync(dir, { recursive: true });
const tmp = join(
dir,
`.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
let renamed = false;
try {
writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
renameSync(tmp, filePath);
renamed = true;
} finally {
if (!renamed) {
try {
unlinkSync(tmp);
} catch {
/* tmp may not exist if writeFileSync threw */
}
}
}
}
/** UTC timestamp for trash naming (YYYYMMDD-HHMMSS). */
function utcTimestamp(d: Date = new Date()): string {
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
/**
* Build the index line for a given entry.
* Format: `- [{name}](file.md) — {description}`
*/
function buildIndexLine(name: string, description: string): string {
return `- [${name}](${name}.md) — ${description}`;
}
/**
* Update MEMORY.md: replace existing line for `name` or append a new one.
* Does not reorder existing lines.
*/
function updateIndexFile(indexPath: string, name: string, description: string): void {
let existing = '';
if (existsSync(indexPath)) {
existing = readFileSync(indexPath, 'utf-8');
}
const prefix = `- [${name}](`;
const newLine = buildIndexLine(name, description);
const lines = existing ? existing.split('\n') : [];
const idx = lines.findIndex((l) => l.startsWith(prefix));
if (idx !== -1) {
lines[idx] = newLine;
} else {
// Remove trailing empty lines, append new entry, add trailing newline
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
lines.push(newLine);
}
writeAtomic(indexPath, lines.join('\n') + '\n');
}
/**
* Re-derives MEMORY.md from the on-disk fact files. Called before every
* upsert / delete to self-heal after a process crash between the two
* atomic writes in upsertMemoryEntry (fact file written, index update
* missed) or in deleteMemoryEntry (fact moved to trash, index line still
* present).
*
* Behavior:
* - Lists memory/*.md (excluding MEMORY.md itself).
* - For each fact file: parse its frontmatter description; if not in the
* index, append a line.
* - For each existing index line: drop it if the referenced *.md file is
* missing from the directory.
* - Preserves the order of existing index lines; new orphans appended at end.
* - Atomic-writes the result only when it differs from the on-disk index.
*
* Returns the names added and removed for visibility / tests.
*/
export function repairMemoryIndex(
rootDir: string,
ownerId: string,
): { added: string[]; removed: string[] } {
const dir = memoryDir(rootDir, ownerId);
const indexPath = join(dir, INDEX_FILE);
const added: string[] = [];
const removed: string[] = [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { added, removed };
throw err;
}
// Build canonical name → description map from the fact files on disk.
const factDescriptions = new Map<string, string>();
for (const entry of entries) {
if (entry === INDEX_FILE) continue;
if (!entry.endsWith('.md')) continue;
const name = entry.slice(0, -3);
if (!MEMORY_NAME_RE.test(name)) continue;
try {
const source = readFileSync(join(dir, entry), 'utf-8');
const parsed = matter(source);
const desc = typeof (parsed.data as Record<string, unknown>)['description'] === 'string'
? String((parsed.data as Record<string, unknown>)['description'])
: '';
factDescriptions.set(name, desc);
} catch {
// Corrupted fact file — skip; will be reconciled by user.
}
}
const existing = existsSync(indexPath) ? readFileSync(indexPath, 'utf-8') : '';
const lines = existing ? existing.split('\n') : [];
const seenInIndex = new Set<string>();
const kept: string[] = [];
for (const line of lines) {
// Match `- [name](file.md) — description` or any line starting with the prefix.
const m = line.match(/^- \[([a-zA-Z0-9_-]+)\]\(/);
if (m) {
const name = m[1]!;
if (factDescriptions.has(name)) {
seenInIndex.add(name);
kept.push(line);
} else {
removed.push(name);
}
} else {
// Non-entry line (blank, comment, etc.) — keep verbatim.
kept.push(line);
}
}
for (const [name, desc] of factDescriptions) {
if (seenInIndex.has(name)) continue;
while (kept.length > 0 && kept[kept.length - 1] === '') kept.pop();
kept.push(buildIndexLine(name, desc));
added.push(name);
}
// Normalize trailing blank lines.
while (kept.length > 0 && kept[kept.length - 1] === '') kept.pop();
const next = kept.length > 0 ? kept.join('\n') + '\n' : '';
if (next !== existing) {
if (next.length === 0) {
if (existsSync(indexPath)) {
try { unlinkSync(indexPath); } catch { /* ignore */ }
}
} else {
writeAtomic(indexPath, next);
}
}
return { added, removed };
}
/**
* Remove the index line for `name` from MEMORY.md.
* If the file is missing or the line is not found, does nothing.
*/
function removeIndexLine(indexPath: string, name: string): void {
if (!existsSync(indexPath)) return;
const existing = readFileSync(indexPath, 'utf-8');
const prefix = `- [${name}](`;
const lines = existing.split('\n').filter((l) => !l.startsWith(prefix));
// Remove trailing blank lines added by filter
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
writeAtomic(indexPath, lines.length > 0 ? lines.join('\n') + '\n' : '');
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Reads MEMORY.md for a user.
* Returns the content (up to 32KB with truncation notice on overflow), or null.
*/
export function readMemoryIndex(rootDir: string, ownerId: string): string | null {
let indexPath: string;
try {
indexPath = join(memoryDir(rootDir, ownerId), INDEX_FILE);
} catch {
return null;
}
let stat;
try {
stat = statSync(indexPath);
} catch {
return null;
}
if (!stat.isFile() || stat.size === 0) return null;
if (stat.size <= MEMORY_INDEX_MAX_BYTES) {
return readFileSync(indexPath, 'utf-8');
}
// Read first MEMORY_INDEX_MAX_BYTES and walk back to a UTF-8 boundary
const buf = Buffer.alloc(MEMORY_INDEX_MAX_BYTES);
const fd = openSync(indexPath, 'r');
let bytesRead: number;
try {
bytesRead = readSync(fd, buf, 0, MEMORY_INDEX_MAX_BYTES, 0);
} finally {
closeSync(fd);
}
let safe = bytesRead;
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
}
/**
* Validates and parses a memory fact file source string.
* Throws on invalid type.
*/
export function parseMemoryEntry(source: string): MemoryEntry {
const parsed = matter(source);
const data = parsed.data as Record<string, unknown>;
const body = parsed.content.startsWith('\n') ? parsed.content.slice(1) : parsed.content;
const name = typeof data['name'] === 'string' ? data['name'] : '';
const description = typeof data['description'] === 'string' ? data['description'] : '';
const rawType = data['type'];
if (!MEMORY_TYPES.includes(rawType as MemoryType)) {
throw new Error(
`invalid memory type: "${rawType}". Must be one of: ${MEMORY_TYPES.join(', ')}`,
);
}
return {
meta: { name, description, type: rawType as MemoryType },
body,
};
}
/**
* Serializes a memory entry to a string (frontmatter + body) in stable key order.
*/
export function serializeMemoryEntry(entry: MemoryEntry): string {
const { meta, body } = entry;
const data: Record<string, unknown> = {
name: meta.name,
description: meta.description,
type: meta.type,
};
return matter.stringify('\n' + body, data);
}
/**
* Writes a fact file atomically AND updates MEMORY.md index.
* Returns the fact file path and whether the index was updated.
*/
export function upsertMemoryEntry(
rootDir: string,
ownerId: string,
opts: { name: string; type: MemoryType; description: string; body: string },
): { path: string; indexUpdated: boolean } {
const { name, type, description, body } = opts;
// Validate name at the helper layer (defense-in-depth)
assertValidMemoryName(name);
// Validate description: must be a single line (no CR or LF)
if (typeof description === 'string' && (description.includes('\n') || description.includes('\r'))) {
throw new Error('memory description must be a single line (no CR/LF)');
}
// Validate type
if (!MEMORY_TYPES.includes(type)) {
throw new Error(`invalid memory type: "${type}". Must be one of: ${MEMORY_TYPES.join(', ')}`);
}
const entry: MemoryEntry = { meta: { name, description, type }, body };
const serialized = serializeMemoryEntry(entry);
const dir = memoryDir(rootDir, ownerId);
const filePath = join(dir, `${name}.md`);
const indexPath = join(dir, INDEX_FILE);
// Self-heal from any previous half-completed upsert / delete (orphan fact
// file, dangling index line) BEFORE applying our own changes. This makes
// the system tolerant of crashes between the two atomic writes below:
// the next memory operation reconciles whatever the previous one missed.
try {
repairMemoryIndex(rootDir, ownerId);
} catch {
// Repair is best-effort; never block an upsert because of it.
}
// Write fact file atomically
writeAtomic(filePath, serialized);
// Update index
updateIndexFile(indexPath, name, description);
return { path: filePath, indexUpdated: true };
}
/**
* Moves a fact file to trash and removes its index line.
* Returns true if the file was found and moved, false if not found.
*/
export function deleteMemoryEntry(rootDir: string, ownerId: string, name: string): boolean {
assertValidMemoryName(name);
const dir = memoryDir(rootDir, ownerId);
const filePath = join(dir, `${name}.md`);
const indexPath = join(dir, INDEX_FILE);
if (!existsSync(filePath)) return false;
// Self-heal stale state from a previous half-completed write before doing
// ours. See the comment in upsertMemoryEntry for the rationale.
try {
repairMemoryIndex(rootDir, ownerId);
} catch {
/* best-effort */
}
// Move to trash
const tDir = trashDir(rootDir, ownerId);
mkdirSync(tDir, { recursive: true });
const ts = utcTimestamp();
const suffix = Math.random().toString(36).slice(2, 8);
const trashedName = `${ts}-${suffix}-${name}.md`;
renameSync(filePath, join(tDir, trashedName));
// Remove index line
removeIndexLine(indexPath, name);
return true;
}
/**
* Alias for `deleteMemoryEntry` used by the reflection applier.
* Moves the fact file to trash and removes its index line.
* Returns true if found and removed, false if not found.
*/
export function removeMemoryEntry(rootDir: string, ownerId: string, name: string): boolean {
return deleteMemoryEntry(rootDir, ownerId, name);
}
/**
* Loads a specific fact file and parses it.
* Returns null if the file does not exist.
*/
export function readMemoryEntry(rootDir: string, ownerId: string, name: string): MemoryEntry | null {
assertValidMemoryName(name);
const filePath = factPath(rootDir, ownerId, name);
if (!existsSync(filePath)) return null;
const source = readFileSync(filePath, 'utf-8');
try {
return parseMemoryEntry(source);
} catch {
return null;
}
}
// ── Read-only helpers (used by reflection engine) ─────────────────────────────
/**
* Returns true when `name` is a syntactically valid memory entry identifier
* (164 chars, alphanumeric / dash / underscore only).
*/
export function isValidMemoryName(name: unknown): name is string {
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 64 &&
MEMORY_NAME_RE.test(name)
);
}
/**
* Lists all memory entries in a memory directory (absolute path).
* Reads every `*.md` file other than `MEMORY.md`, parses the frontmatter, and
* returns an array of `{ name, description, type, body }` objects.
* Entries that cannot be parsed are silently skipped.
*/
export function listMemoryEntries(
memDir: string,
): Array<{ name: string; description: string; type: string; body: string }> {
let files: string[];
try {
files = readdirSync(memDir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
const results: Array<{ name: string; description: string; type: string; body: string }> = [];
for (const file of files) {
if (file === INDEX_FILE) continue;
if (!file.endsWith('.md')) continue;
const name = file.slice(0, -3);
if (!isValidMemoryName(name)) continue;
try {
const source = readFileSync(join(memDir, file), 'utf-8');
const entry = parseMemoryEntry(source);
results.push({
name: entry.meta.name || name,
description: entry.meta.description,
type: entry.meta.type,
body: entry.body,
});
} catch {
// corrupted file — skip
}
}
return results;
}
/**
* Reads MEMORY.md from an absolute memory directory path.
* Returns the content (up to 32KB with truncation notice), or null if missing.
*
* This is a thin convenience wrapper over the two-argument `readMemoryIndex`
* for callers that already have the resolved directory path.
*/
export function readMemoryIndexFromDir(memDir: string): string | null {
const indexPath = join(memDir, INDEX_FILE);
let stat;
try {
stat = statSync(indexPath);
} catch {
return null;
}
if (!stat.isFile() || stat.size === 0) return null;
if (stat.size <= MEMORY_INDEX_MAX_BYTES) {
return readFileSync(indexPath, 'utf-8');
}
const buf = Buffer.alloc(MEMORY_INDEX_MAX_BYTES);
const fd = openSync(indexPath, 'r');
let bytesRead: number;
try {
bytesRead = readSync(fd, buf, 0, MEMORY_INDEX_MAX_BYTES, 0);
} finally {
closeSync(fd);
}
let safe = bytesRead;
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { ensureUserFolder, USER_SUBDIRS } from './paths.js';
describe('ensureUserFolder with notes subdir', () => {
let tmpRoot: string;
beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'ensure-notes-test-')); });
afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); });
it('USER_SUBDIRS contains "notes"', () => {
expect(USER_SUBDIRS).toContain('notes' as any);
});
it('creates notes/ inside user folder', () => {
ensureUserFolder(tmpRoot, 'alice');
expect(existsSync(join(tmpRoot, 'alice', 'notes'))).toBe(true);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveUserSubdir, ensureUserFolder, assertOwnerAccess, userRoot, readUserAgentsMd } from './paths.js';
describe('user-folder/paths', () => {
let root: string;
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'uf-')); });
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
it('creates the standard subdirs on first ensure', () => {
ensureUserFolder(root, 'user-abc');
for (const sub of ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets']) {
expect(existsSync(join(root, 'user-abc', sub))).toBe(true);
}
});
it('is idempotent — second call does not throw', () => {
ensureUserFolder(root, 'user-abc');
expect(() => ensureUserFolder(root, 'user-abc')).not.toThrow();
});
it('rejects empty ownerId', () => {
expect(() => ensureUserFolder(root, '')).toThrow(/invalid ownerId/);
});
it('rejects ownerId containing path separators', () => {
expect(() => ensureUserFolder(root, '../escape')).toThrow(/invalid ownerId/);
expect(() => ensureUserFolder(root, 'a/b')).toThrow(/invalid ownerId/);
});
it('rejects absolute-path ownerId', () => {
expect(() => ensureUserFolder(root, '/etc')).toThrow(/invalid ownerId/);
});
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'));
});
it('rejects path traversal in the relative segment', () => {
expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', '../../etc/passwd'))
.toThrow(/outside owner folder/);
});
it('rejects empty relPath', () => {
expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', ''))
.toThrow(/relPath must not be empty/);
});
it('assertOwnerAccess passes when ctx.userId === ownerId', () => {
expect(() => assertOwnerAccess({ userId: 'u1' }, 'u1')).not.toThrow();
});
it('assertOwnerAccess throws when ctx.userId is missing', () => {
expect(() => assertOwnerAccess({}, 'u1')).toThrow(/unauthenticated/);
});
it('assertOwnerAccess throws on cross-user access', () => {
expect(() => assertOwnerAccess({ userId: 'u1' }, 'u2')).toThrow(/forbidden/);
});
it('readUserAgentsMd returns null when file is missing', () => {
ensureUserFolder(root, 'u1');
expect(readUserAgentsMd(root, 'u1')).toBeNull();
});
it('readUserAgentsMd returns null for an empty file', () => {
ensureUserFolder(root, 'u1');
writeFileSync(join(userRoot(root, 'u1'), 'AGENTS.md'), '');
expect(readUserAgentsMd(root, 'u1')).toBeNull();
});
it('readUserAgentsMd returns the file contents under the cap', () => {
ensureUserFolder(root, 'u1');
writeFileSync(join(userRoot(root, 'u1'), 'AGENTS.md'), 'hello world');
expect(readUserAgentsMd(root, 'u1')).toBe('hello world');
});
it('readUserAgentsMd truncates files larger than 64 KB and appends a notice', () => {
ensureUserFolder(root, 'u1');
const big = Buffer.alloc(66 * 1024, 0x41); // 66 KB of 'A'
writeFileSync(join(userRoot(root, 'u1'), 'AGENTS.md'), big);
const result = readUserAgentsMd(root, 'u1')!;
expect(result).toContain('[truncated: original was');
expect(result.length).toBeLessThan(66 * 1024 + 100);
});
it('readUserAgentsMd returns null for invalid ownerId', () => {
expect(readUserAgentsMd(root, '../escape')).toBeNull();
expect(readUserAgentsMd(root, '')).toBeNull();
});
});
+125
View File
@@ -0,0 +1,125 @@
import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync } from 'fs';
import { resolve, join, relative, isAbsolute } from 'path';
const USER_AGENTS_MAX_BYTES = 64 * 1024;
export const USER_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const;
export type UserSubdir = typeof USER_SUBDIRS[number];
export function userRoot(rootDir: string, ownerId: string): string {
if (!ownerId || ownerId.includes('/') || ownerId.includes('\\') || ownerId.includes('\0') || isAbsolute(ownerId)) {
throw new Error(`invalid ownerId: ${JSON.stringify(ownerId)}`);
}
return resolve(rootDir, ownerId);
}
export function ensureUserFolder(rootDir: string, ownerId: string): string {
const root = userRoot(rootDir, ownerId);
if (!existsSync(root)) {
mkdirSync(root, { recursive: true, mode: 0o700 });
chmodSync(root, 0o700);
}
for (const sub of USER_SUBDIRS) {
const p = join(root, sub);
if (!existsSync(p)) {
mkdirSync(p, { recursive: true, mode: 0o700 });
chmodSync(p, 0o700);
}
}
return root;
}
export function resolveUserSubdir(
rootDir: string,
ownerId: string,
subdir: UserSubdir,
relPath: string,
): string {
if (!relPath) throw new Error('relPath must not be empty');
if (isAbsolute(relPath)) throw new Error('relative path required');
const base = join(userRoot(rootDir, ownerId), subdir);
const full = resolve(base, relPath);
const rel = relative(base, full);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error('path traversal: target outside owner folder');
}
return full;
}
/**
* Returns the absolute path to a user's memory directory:
* {rootDir}/{ownerId}/memory
* Does not create the directory — callers that need it to exist should call
* ensureUserFolder first.
*/
export function userMemoryDir(rootDir: string, ownerId: string): string {
return join(userRoot(rootDir, ownerId), 'memory');
}
/**
* Returns the absolute path to a user's custom pieces directory:
* {rootDir}/{ownerId}/pieces
* Does not create the directory.
*/
export function userPiecesDir(rootDir: string, ownerId: string): string {
return join(userRoot(rootDir, ownerId), 'pieces');
}
export function assertOwnerAccess(ctx: { userId?: string }, ownerId: string): void {
if (!ctx.userId) throw new Error('unauthenticated: ctx.userId missing');
if (ctx.userId !== ownerId) throw new Error('forbidden: cross-user access');
}
export function writeUserAgentsMd(rootDir: string, ownerId: string, content: string): void {
if (Buffer.byteLength(content, 'utf-8') > USER_AGENTS_MAX_BYTES) {
throw new Error(`AGENTS.md exceeds ${USER_AGENTS_MAX_BYTES} bytes`);
}
const userDir = ensureUserFolder(rootDir, ownerId);
const path = join(userDir, 'AGENTS.md');
writeFileSync(path, content, 'utf-8');
}
export function deleteUserAgentsMd(rootDir: string, ownerId: string): boolean {
let path: string;
try {
path = join(userRoot(rootDir, ownerId), 'AGENTS.md');
} catch {
return false;
}
if (!existsSync(path)) return false;
unlinkSync(path);
return true;
}
export function readUserAgentsMd(rootDir: string, ownerId: string): string | null {
// Validates ownerId via userRoot's existing guard.
let path: string;
try {
path = join(userRoot(rootDir, ownerId), 'AGENTS.md');
} catch {
return null; // bad ownerId — same as missing file
}
let stat;
try {
stat = statSync(path);
} catch {
return null;
}
if (!stat.isFile() || stat.size === 0) return null;
if (stat.size <= USER_AGENTS_MAX_BYTES) {
return readFileSync(path, 'utf-8');
}
const buf = Buffer.alloc(USER_AGENTS_MAX_BYTES);
const fd = openSync(path, 'r');
let bytesRead: number;
try {
bytesRead = readSync(fd, buf, 0, USER_AGENTS_MAX_BYTES, 0);
} finally {
closeSync(fd);
}
// Walk back to a UTF-8 codepoint boundary so we don't slice mid-character
// (avoids U+FFFD replacement char for CJK / emoji content).
let safe = bytesRead;
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
return buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes]`;
}
+492
View File
@@ -0,0 +1,492 @@
import AdmZip from 'adm-zip';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { basename, dirname, extname, join, posix, relative, resolve, isAbsolute } from 'path';
import { ensureUserFolder, resolveUserSubdir, userRoot } from './paths.js';
const MAX_ZIP_BYTES = 12 * 1024 * 1024;
const MAX_UNCOMPRESSED_BYTES = 10 * 1024 * 1024;
const MAX_SINGLE_FILE_BYTES = 5 * 1024 * 1024;
const MAX_FILE_COUNT = 32;
const PET_ID_REGEX = /^[a-z0-9_-]{1,64}$/;
const WORKER_ID_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
const MAX_WORKER_PETS_ENTRIES = 64;
const ALLOWED_EXTENSIONS = new Set(['.json', '.png', '.webp']);
const ALLOWED_SIZES = new Set([32, 48, 64, 80]);
export interface PetSettings {
enabled: boolean;
activePetId: string | null;
size: 32 | 48 | 64 | 80;
position: 'bottom-right';
sound: boolean;
reducedMotion: boolean;
toolSparkEnabled: boolean;
workerPets: Record<string, string>;
}
export interface PetSummary {
id: string;
name: string;
description: string | null;
spriteFile: string | null;
previewFile: string | null;
frameWidth: number | null;
frameHeight: number | null;
gridCols: number | null;
gridRows: number | null;
updatedAt: string;
}
export interface PetDetail extends PetSummary {
manifest: Record<string, unknown>;
}
export const DEFAULT_PET_SETTINGS: PetSettings = {
enabled: true,
activePetId: null,
size: 64,
position: 'bottom-right',
sound: false,
reducedMotion: false,
toolSparkEnabled: true,
workerPets: {},
};
export class PetConflictError extends Error {
constructor(public readonly petId: string) {
super(`Pet already exists: ${petId}`);
this.name = 'PetConflictError';
}
}
export class PetValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'PetValidationError';
}
}
const utcTimestamp = (d: Date): string => {
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
};
const writeAtomic = (path: string, content: string): void => {
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
let renamed = false;
try {
writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
renameSync(tmp, path);
renamed = true;
} finally {
if (!renamed) {
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
}
}
};
export const slugifyPetId = (input: string | null | undefined): string => {
const slug = (input ?? '')
.trim()
.toLowerCase()
.replace(/\.[^.]+$/, '')
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
return PET_ID_REGEX.test(slug) ? slug : `pet-${Date.now().toString(36)}`;
};
const settingsPath = (rootDir: string, ownerId: string): string =>
join(userRoot(rootDir, ownerId), 'pet-settings.json');
const trashPath = (rootDir: string, ownerId: string, name: string): string => {
const trashDir = join(userRoot(rootDir, ownerId), 'trash');
mkdirSync(trashDir, { recursive: true, mode: 0o700 });
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
return join(trashDir, `${ts}-${suffix}-${name}`);
};
const isZipSymlink = (entry: AdmZip.IZipEntry): boolean => {
const mode = (entry.header.attr >> 16) & 0o170000;
return mode === 0o120000;
};
const normalizeZipPath = (entryName: string): string => {
const normalized = entryName.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
if (!normalized || normalized.includes('\0')) {
throw new PetValidationError('zip contains an empty or invalid path');
}
const parts = normalized.split('/');
if (parts.some(part => !part || part === '.' || part === '..' || part.startsWith('.'))) {
throw new PetValidationError(`zip contains an unsafe path: ${entryName}`);
}
if (posix.normalize(normalized) !== normalized) {
throw new PetValidationError(`zip contains a non-normal path: ${entryName}`);
}
return normalized;
};
const stripBasePrefix = (entryPath: string, basePrefix: string): string | null => {
if (!basePrefix) return entryPath;
if (!entryPath.startsWith(basePrefix)) return null;
const rel = entryPath.slice(basePrefix.length);
return rel || null;
};
const readManifest = (path: string): Record<string, unknown> => {
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('manifest must be an object');
}
return parsed as Record<string, unknown>;
} catch (err) {
throw new PetValidationError(`invalid pet.json: ${(err as Error).message}`);
}
};
const pickString = (value: unknown): string | null =>
typeof value === 'string' && value.trim() ? value.trim() : null;
const pickPositiveInt = (
manifest: Record<string, unknown>,
keys: string[],
min: number,
max: number,
): number | null => {
const tryRead = (source: Record<string, unknown>): number | null => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max) {
return value;
}
}
return null;
};
const direct = tryRead(manifest);
if (direct !== null) return direct;
for (const containerKey of ['spritesheet', 'spriteSheet', 'sprite']) {
const nested = manifest[containerKey];
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
const fromNested = tryRead(nested as Record<string, unknown>);
if (fromNested !== null) return fromNested;
}
}
return null;
};
const pickAsset = (manifest: Record<string, unknown>, keys: string[], files: Set<string>): string | null => {
for (const key of keys) {
const value = pickString(manifest[key]);
if (value && files.has(value)) return value;
}
for (const key of keys) {
const nested = manifest[key];
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
const file = pickString((nested as Record<string, unknown>)['file']);
if (file && files.has(file)) return file;
}
}
return null;
};
const summarizePetDir = (rootDir: string, ownerId: string, petId: string): PetDetail | null => {
let dir: string;
try {
dir = resolveUserSubdir(rootDir, ownerId, 'pets', petId);
} catch {
return null;
}
if (!existsSync(dir) || !statSync(dir).isDirectory()) return null;
const manifestPath = join(dir, 'pet.json');
if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) return null;
const manifest = readManifest(manifestPath);
const entries = readdirSync(dir, { withFileTypes: true });
const files = new Set(entries.filter(e => e.isFile()).map(e => e.name));
const spriteFile =
pickAsset(manifest, ['spritesheetPath', 'spritesheet', 'spriteSheet', 'sprite', 'image'], files) ??
(files.has('spritesheet.webp') ? 'spritesheet.webp' : null) ??
(files.has('spritesheet.png') ? 'spritesheet.png' : null);
const previewFile =
pickAsset(manifest, ['preview', 'thumbnail', 'icon'], files) ??
(files.has('preview.png') ? 'preview.png' : null) ??
(files.has('preview.webp') ? 'preview.webp' : null);
const frameWidth = pickPositiveInt(manifest, ['frameWidth', 'frame_width', 'frameW'], 1, 4096);
const frameHeight = pickPositiveInt(manifest, ['frameHeight', 'frame_height', 'frameH'], 1, 4096);
const gridCols = pickPositiveInt(manifest, ['gridCols', 'cols', 'columns'], 1, 64);
const gridRows = pickPositiveInt(manifest, ['gridRows', 'rows'], 1, 64);
const stat = statSync(manifestPath);
return {
id: petId,
name: pickString(manifest['displayName']) ?? pickString(manifest['name']) ?? petId,
description: pickString(manifest['description']),
spriteFile,
previewFile,
frameWidth: frameWidth !== null && frameHeight !== null ? frameWidth : null,
frameHeight: frameWidth !== null && frameHeight !== null ? frameHeight : null,
gridCols,
gridRows,
updatedAt: stat.mtime.toISOString(),
manifest,
};
};
const validateSettings = (input: unknown, previous: PetSettings = DEFAULT_PET_SETTINGS): PetSettings => {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new PetValidationError('settings must be an object');
}
const raw = input as Record<string, unknown>;
const next: PetSettings = { ...previous };
if ('enabled' in raw) {
if (typeof raw.enabled !== 'boolean') throw new PetValidationError('enabled must be boolean');
next.enabled = raw.enabled;
}
if ('toolSparkEnabled' in raw) {
if (typeof raw.toolSparkEnabled !== 'boolean') throw new PetValidationError('toolSparkEnabled must be boolean');
next.toolSparkEnabled = raw.toolSparkEnabled;
}
if ('sound' in raw) {
if (typeof raw.sound !== 'boolean') throw new PetValidationError('sound must be boolean');
next.sound = raw.sound;
}
if ('reducedMotion' in raw) {
if (typeof raw.reducedMotion !== 'boolean') throw new PetValidationError('reducedMotion must be boolean');
next.reducedMotion = raw.reducedMotion;
}
if ('activePetId' in raw) {
if (raw.activePetId !== null && typeof raw.activePetId !== 'string') {
throw new PetValidationError('activePetId must be string or null');
}
if (typeof raw.activePetId === 'string' && raw.activePetId && !PET_ID_REGEX.test(raw.activePetId)) {
throw new PetValidationError('activePetId is invalid');
}
next.activePetId = raw.activePetId === '' ? null : raw.activePetId;
}
if ('size' in raw) {
if (typeof raw.size !== 'number' || !ALLOWED_SIZES.has(raw.size)) {
throw new PetValidationError('size must be one of 32, 48, 64, 80');
}
next.size = raw.size as PetSettings['size'];
}
if ('position' in raw) {
if (raw.position !== 'bottom-right') throw new PetValidationError('position must be bottom-right');
next.position = 'bottom-right';
}
if ('workerPets' in raw) {
const wp = raw.workerPets;
if (!wp || typeof wp !== 'object' || Array.isArray(wp)) {
throw new PetValidationError('workerPets must be an object');
}
const entries = Object.entries(wp as Record<string, unknown>);
if (entries.length > MAX_WORKER_PETS_ENTRIES) {
throw new PetValidationError(`workerPets has too many entries (max ${MAX_WORKER_PETS_ENTRIES})`);
}
const accepted: Record<string, string> = {};
for (const [key, value] of entries) {
if (!WORKER_ID_REGEX.test(key)) {
throw new PetValidationError(`workerPets key is invalid: ${key}`);
}
if (value === null || value === '') {
// explicit removal via empty value
continue;
}
if (typeof value !== 'string' || !PET_ID_REGEX.test(value)) {
throw new PetValidationError(`workerPets[${key}] must be a valid pet id`);
}
accepted[key] = value;
}
next.workerPets = accepted;
}
return next;
};
export const readPetSettings = (rootDir: string, ownerId: string): PetSettings => {
ensureUserFolder(rootDir, ownerId);
const path = settingsPath(rootDir, ownerId);
if (!existsSync(path)) return { ...DEFAULT_PET_SETTINGS };
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
return validateSettings(parsed, DEFAULT_PET_SETTINGS);
} catch {
const trashed = trashPath(rootDir, ownerId, 'pet-settings.json');
try { renameSync(path, trashed); } catch { /* ignore failed quarantine */ }
return { ...DEFAULT_PET_SETTINGS };
}
};
export const writePetSettings = (
rootDir: string,
ownerId: string,
patch: unknown,
): PetSettings => {
ensureUserFolder(rootDir, ownerId);
const previous = readPetSettings(rootDir, ownerId);
const next = validateSettings(patch, previous);
writeAtomic(settingsPath(rootDir, ownerId), JSON.stringify(next, null, 2));
return next;
};
export const listPets = (rootDir: string, ownerId: string): PetSummary[] => {
ensureUserFolder(rootDir, ownerId);
const petsDir = join(userRoot(rootDir, ownerId), 'pets');
const entries = readdirSync(petsDir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory() && PET_ID_REGEX.test(e.name))
.map(e => summarizePetDir(rootDir, ownerId, e.name))
.filter((pet): pet is PetDetail => pet !== null)
.map(({ manifest: _manifest, ...summary }) => summary)
.sort((a, b) => a.name.localeCompare(b.name));
};
export const getPet = (rootDir: string, ownerId: string, petId: string): PetDetail | null => {
if (!PET_ID_REGEX.test(petId)) return null;
ensureUserFolder(rootDir, ownerId);
return summarizePetDir(rootDir, ownerId, petId);
};
export const importPetZip = (
rootDir: string,
ownerId: string,
zipBytes: Buffer,
options: { preferredId?: string | null; overwrite?: boolean } = {},
): PetDetail => {
if (zipBytes.length === 0) throw new PetValidationError('zip body is empty');
if (zipBytes.length > MAX_ZIP_BYTES) throw new PetValidationError('zip body is too large');
ensureUserFolder(rootDir, ownerId);
const zip = new AdmZip(zipBytes);
const entries = zip.getEntries();
if (entries.length === 0) throw new PetValidationError('zip is empty');
const normalizedEntries = entries.map(entry => ({
entry,
path: normalizeZipPath(entry.entryName),
}));
const manifestEntry = normalizedEntries.find(({ entry, path }) => !entry.isDirectory && basename(path) === 'pet.json');
if (!manifestEntry) throw new PetValidationError('pet.json is required');
const basePrefix = dirname(manifestEntry.path) === '.' ? '' : `${dirname(manifestEntry.path)}/`;
const parsedManifest = JSON.parse(manifestEntry.entry.getData().toString('utf-8')) as unknown;
if (!parsedManifest || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) {
throw new PetValidationError('invalid pet.json: manifest must be an object');
}
const manifest = parsedManifest as Record<string, unknown>;
const preferred = options.preferredId ?? pickString(manifest['id']) ?? pickString(manifest['name']) ?? basename(basePrefix || 'pet');
const petId = slugifyPetId(preferred);
const destDir = resolveUserSubdir(rootDir, ownerId, 'pets', petId);
if (existsSync(destDir) && !options.overwrite) {
throw new PetConflictError(petId);
}
const petsDir = join(userRoot(rootDir, ownerId), 'pets');
mkdirSync(petsDir, { recursive: true, mode: 0o700 });
const tmpDir = join(petsDir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
let fileCount = 0;
let totalBytes = 0;
let wroteManifest = false;
try {
for (const { entry, path } of normalizedEntries) {
if (entry.isDirectory) continue;
if (isZipSymlink(entry)) throw new PetValidationError('zip symlinks are not allowed');
const relPath = stripBasePrefix(path, basePrefix);
if (!relPath) continue;
if (relPath.includes('/')) throw new PetValidationError(`nested files are not supported: ${relPath}`);
const ext = extname(relPath).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) throw new PetValidationError(`unsupported file type: ${relPath}`);
const data = entry.getData();
fileCount += 1;
totalBytes += data.length;
if (fileCount > MAX_FILE_COUNT) throw new PetValidationError('zip contains too many files');
if (data.length > MAX_SINGLE_FILE_BYTES) throw new PetValidationError(`file is too large: ${relPath}`);
if (totalBytes > MAX_UNCOMPRESSED_BYTES) throw new PetValidationError('zip uncompressed size is too large');
const outPath = resolve(tmpDir, relPath);
if (relative(tmpDir, outPath).startsWith('..') || isAbsolute(relative(tmpDir, outPath))) {
throw new PetValidationError(`unsafe output path: ${relPath}`);
}
writeFileSync(outPath, data, { mode: 0o600 });
if (relPath === 'pet.json') wroteManifest = true;
}
if (!wroteManifest) throw new PetValidationError('pet.json is required at package root');
readManifest(join(tmpDir, 'pet.json'));
if (existsSync(destDir)) {
renameSync(destDir, trashPath(rootDir, ownerId, `${petId}-pet`));
}
renameSync(tmpDir, destDir);
} catch (err) {
rmSync(tmpDir, { recursive: true, force: true });
throw err;
}
const detail = getPet(rootDir, ownerId, petId);
if (!detail) throw new PetValidationError('imported pet could not be read');
return detail;
};
export const deletePet = (rootDir: string, ownerId: string, petId: string): boolean => {
if (!PET_ID_REGEX.test(petId)) return false;
ensureUserFolder(rootDir, ownerId);
const dir = resolveUserSubdir(rootDir, ownerId, 'pets', petId);
if (!existsSync(dir) || !statSync(dir).isDirectory()) return false;
renameSync(dir, trashPath(rootDir, ownerId, `${petId}-pet`));
const settings = readPetSettings(rootDir, ownerId);
const cleanedWorkerPets = Object.fromEntries(
Object.entries(settings.workerPets).filter(([, mappedId]) => mappedId !== petId),
);
const workerPetsChanged =
Object.keys(cleanedWorkerPets).length !== Object.keys(settings.workerPets).length;
if (settings.activePetId === petId || workerPetsChanged) {
writePetSettings(rootDir, ownerId, {
activePetId: settings.activePetId === petId ? null : settings.activePetId,
workerPets: cleanedWorkerPets,
});
}
return true;
};
export const resolvePetAsset = (
rootDir: string,
ownerId: string,
petId: string,
file: string,
): { path: string; contentType: string } | null => {
if (!PET_ID_REGEX.test(petId)) return null;
if (!file || file.includes('/') || file.includes('\\') || file.startsWith('.') || file.includes('\0')) return null;
const ext = extname(file).toLowerCase();
const contentType =
ext === '.png' ? 'image/png' :
ext === '.webp' ? 'image/webp' :
ext === '.json' ? 'application/json; charset=utf-8' :
null;
if (!contentType) return null;
const path = resolveUserSubdir(rootDir, ownerId, 'pets', `${petId}/${file}`);
if (!existsSync(path) || !statSync(path).isFile()) return null;
return { path, contentType };
};
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { recorder } from '../engine/browser-recorder.js';
import { flushAndStageRecording } from './recording-flush.js';
import { parseScript } from './frontmatter.js';
import { logger } from '../logger.js';
// ── Fixtures ──────────────────────────────────────────────────────────────────
const SCRIPT_FOO = `\
---
description: Log into example.com
params:
- name: username
type: string
created_at: '2026-01-01T00:00:00.000Z'
---
async function main({ context, params }) {
const page = await context.newPage();
try {
await page.goto('https://example.com');
} finally {
await page.close();
}
}
module.exports = main;
`;
const TEST_USER = 'flush-test-user';
// ── Setup ─────────────────────────────────────────────────────────────────────
let tempDir: string;
let userFolderRoot: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'recording-flush-test-'));
userFolderRoot = join(tempDir, 'users');
mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true });
mkdirSync(join(userFolderRoot, TEST_USER, 'recordings'), { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeTaskId(): string {
return `flush-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function recordSomeActions(taskId: string): void {
recorder.record(taskId, { type: 'goto', url: 'https://example.com' });
recorder.record(taskId, { type: 'click', selector: '#btn' });
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('flushAndStageRecording', () => {
it('flushes a regular recording without staging a patch', () => {
const taskId = makeTaskId();
recorder.enable(taskId, 'my-rec');
recordSomeActions(taskId);
flushAndStageRecording(taskId, TEST_USER, userFolderRoot);
// Recording file should exist
const recPath = join(userFolderRoot, TEST_USER, 'recordings', 'my-rec.json');
expect(existsSync(recPath)).toBe(true);
// Patch file should NOT exist (no .next suffix)
const patchPath = join(userFolderRoot, TEST_USER, 'browser-macros', 'my-rec.next.js');
expect(existsSync(patchPath)).toBe(false);
});
it('is a no-op when ownerId is undefined', () => {
const taskId = makeTaskId();
recorder.enable(taskId, 'my-rec');
recordSomeActions(taskId);
// Should not throw
expect(() => flushAndStageRecording(taskId, undefined, userFolderRoot)).not.toThrow();
// Nothing should have been flushed (recorder still holds the buffer)
// Actually the buffer is not flushed since we returned early
recorder.cancel(taskId); // clean up
});
it('is a no-op when recorder is not enabled for the taskId', () => {
const taskId = makeTaskId();
// No recorder.enable call
expect(() => flushAndStageRecording(taskId, TEST_USER, userFolderRoot)).not.toThrow();
});
it('stages a .next.js patch in browser-macros/ when recordTo ends in .next', () => {
const taskId = makeTaskId();
// Write source script in browser-macros/
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO);
recorder.enable(taskId, 'foo.next');
recordSomeActions(taskId);
flushAndStageRecording(taskId, TEST_USER, userFolderRoot);
// Recording JSON should exist
const recPath = join(userFolderRoot, TEST_USER, 'recordings', 'foo.next.json');
expect(existsSync(recPath)).toBe(true);
// Patch script should exist in browser-macros/
const patchPath = join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.next.js');
expect(existsSync(patchPath)).toBe(true);
// Parse the patch and verify description is preserved from foo.js
const patchText = readFileSync(patchPath, 'utf-8');
const parsed = parseScript(patchText);
expect(parsed.frontmatter.description).toBe('Log into example.com');
// createdAt should be preserved from the original script
expect(parsed.frontmatter.createdAt).toBe('2026-01-01T00:00:00.000Z');
// updatedAt should be a fresh ISO timestamp
expect(parsed.frontmatter.updatedAt).toBeDefined();
expect(parsed.frontmatter.updatedAt).not.toBe('2026-01-01T00:00:00.000Z');
expect(() => new Date(parsed.frontmatter.updatedAt!)).not.toThrow();
});
it('logs a warning if the source script is missing and still flushes the recording', () => {
const taskId = makeTaskId();
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
recorder.enable(taskId, 'phantom.next');
recordSomeActions(taskId);
flushAndStageRecording(taskId, TEST_USER, userFolderRoot);
// Recording should still be flushed
const recPath = join(userFolderRoot, TEST_USER, 'recordings', 'phantom.next.json');
expect(existsSync(recPath)).toBe(true);
// Patch should NOT exist (no source script in browser-macros/)
const patchPath = join(userFolderRoot, TEST_USER, 'browser-macros', 'phantom.next.js');
expect(existsSync(patchPath)).toBe(false);
// logger.warn should have been called with a message about the source script
const warnCalls = warnSpy.mock.calls.map(c => c[0] as string);
expect(warnCalls.some(msg => msg.includes('source script'))).toBe(true);
warnSpy.mockRestore();
});
it('does not flush when the buffer is empty', () => {
const taskId = makeTaskId();
recorder.enable(taskId, 'empty-rec');
// No actions recorded
flushAndStageRecording(taskId, TEST_USER, userFolderRoot);
const recPath = join(userFolderRoot, TEST_USER, 'recordings', 'empty-rec.json');
expect(existsSync(recPath)).toBe(false);
});
it('does not substitute empty-string fill values with params on a .next.js patch', () => {
const taskId = makeTaskId();
// Write source script in browser-macros/
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO);
recorder.enable(taskId, 'foo.next');
// Record an empty-string fill action (e.g., clearing a checkbox)
recorder.record(taskId, { type: 'fill', selector: '#empty-field', value: '', frameChain: [] });
flushAndStageRecording(taskId, TEST_USER, userFolderRoot);
// Patch script should exist in browser-macros/
const patchPath = join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.next.js');
expect(existsSync(patchPath)).toBe(true);
// Parse and verify the patch contains literal empty-string fill, not params.username
const patchText = readFileSync(patchPath, 'utf-8');
expect(patchText).toContain('.fill("")');
expect(patchText).not.toContain('params.username');
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* recording-flush.ts
*
* Called at task end to:
* 1. Flush any buffered recorder actions to recordings/{recordTo}.json.
* 2. If recordTo ends with ".next", compile a candidate patch script at
* scripts/{baseName}.next.js using the source script's description and
* params as hints (self-healing patch staging).
*
* This helper is intentionally non-throwing — all errors are logged and
* swallowed so a flush bug cannot crash the agent loop.
*/
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { recorder } from '../engine/browser-recorder.js';
import { parseScript, serializeScript } from './frontmatter.js';
import { compileScriptBody } from './script-compiler.js';
import { resolveUserSubdir } from './paths.js';
import { logger } from '../logger.js';
import type { RecordedAction } from '../engine/browser-recorder.js';
export function flushAndStageRecording(
taskId: string,
ownerId: string | undefined,
userFolderRoot: string,
): void {
if (!ownerId) return;
const recordTo = recorder.recordTo(taskId);
if (!recordTo) return;
const flushedPath = recorder.flush(taskId, userFolderRoot, ownerId);
if (!flushedPath) return;
if (!recordTo.endsWith('.next')) {
logger.debug(`[recording] flushed regular recording: ${recordTo}.json`);
return;
}
// Self-healing patch: compile a candidate .next.js from the flushed trace.
const baseName = recordTo.slice(0, -'.next'.length);
try {
const sourceScriptPath = resolveUserSubdir(
userFolderRoot,
ownerId,
'browser-macros',
`${baseName}.js`,
);
if (!existsSync(sourceScriptPath)) {
logger.warn(
`[recording] cannot stage patch — source script ${baseName}.js missing`,
);
return;
}
const sourceText = readFileSync(sourceScriptPath, 'utf-8');
const sourceParsed = parseScript(sourceText);
const recordingPayload = JSON.parse(readFileSync(flushedPath, 'utf-8')) as {
actions: RecordedAction[];
};
// Compile just the body (we build frontmatter ourselves to control timestamps)
const { body, paramSpecs } = compileScriptBody({
recording: recordingPayload.actions,
description: sourceParsed.frontmatter.description,
sessionProfileId: sourceParsed.frontmatter.sessionProfileId,
// We don't know which fill values map to which params after a recovery session.
// Pass no paramHints so the patch contains literal values; the user will
// manually re-parameterize during diff review.
paramHints: [],
recordingSource: `${recordTo}.json`,
});
const now = new Date().toISOString();
const patched = serializeScript({
frontmatter: {
description: sourceParsed.frontmatter.description,
params: paramSpecs,
sessionProfileId: sourceParsed.frontmatter.sessionProfileId,
recordingSource: `${recordTo}.json`,
// Preserve original createdAt; stamp fresh updatedAt.
createdAt: sourceParsed.frontmatter.createdAt ?? now,
updatedAt: now,
},
body,
});
const targetPath = resolveUserSubdir(
userFolderRoot,
ownerId,
'browser-macros',
`${baseName}.next.js`,
);
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
mkdirSync(dirname(targetPath), { recursive: true });
writeFileSync(tmpPath, patched, { encoding: 'utf-8', mode: 0o600 });
renameSync(tmpPath, targetPath);
logger.info(`[user-folder] staged self-healing patch: browser-macros/${baseName}.next.js`);
} catch (e) {
logger.warn(`[recording] failed to stage patch: ${(e as Error).message}`);
}
}
@@ -0,0 +1,128 @@
/**
* recording-to-run.e2e.test.ts
*
* End-to-end coverage for the path users actually take:
* 1. browser-recorder captures a sequence of actions (we synthesize one
* directly — recorder unit tests already exercise the capture logic).
* 2. compileScript turns the recording into a runnable browser-macro source.
* 3. The compiled source is written to disk and executed via runUserScript
* against a real headless chromium.
* 4. The script's returned value matches what the recorded actions would
* have produced if a human ran them.
*
* Until now each leg had unit tests, but the glue between them was only
* verified by hand. This catches regressions where the compiler emits valid
* code that the runtime can't actually execute.
*
* Gated behind SKIP_PLAYWRIGHT_E2E=1 — chromium spin-up is ~2-5s per case.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { writeFileSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { RecordedAction } from '../engine/browser-recorder.js';
import { compileScript } from './script-compiler.js';
import { runUserScript } from './script-runner.js';
const skipPlaywright = process.env['SKIP_PLAYWRIGHT_E2E'] === '1';
const tmpFiles: string[] = [];
afterEach(() => {
for (const f of tmpFiles) {
try { unlinkSync(f); } catch { /* already gone */ }
}
tmpFiles.length = 0;
});
function writeTmpScript(source: string): string {
const path = join(tmpdir(), `recording-e2e-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`);
writeFileSync(path, source, 'utf-8');
tmpFiles.push(path);
return path;
}
function action(
type: RecordedAction['type'],
extras: Partial<Omit<RecordedAction, 'type' | 'ts'>> = {},
): RecordedAction {
return { type, ts: '2026-05-11T00:00:00Z', ...extras };
}
describe.skipIf(skipPlaywright)('recording → compileScript → runUserScript (E2E)', () => {
it('compiles a recording with goto + click + getText and executes it', async () => {
// Recording captured against a synthetic data: URL page so the test
// doesn't depend on an external HTTP server.
const html = `<!doctype html><html><body>
<button id="go" onclick="document.getElementById('out').textContent='clicked-result'">Go</button>
<span id="out"></span>
</body></html>`;
const dataUrl = 'data:text/html,' + encodeURIComponent(html);
const recording: RecordedAction[] = [
action('goto', { url: dataUrl }),
action('click', { selector: '#go' }),
action('getText', { selector: '#out' }),
];
const { source } = compileScript({
recording,
description: 'E2E pipeline smoke test',
});
// Sanity check: the compiler emitted something we expect to run.
expect(source).toContain('await page.goto(');
expect(source).toContain("page.locator('#go').click()");
expect(source).toContain('return __text;');
const scriptPath = writeTmpScript(source);
const result = await runUserScript({
scriptPath,
params: {},
runtime: 'playwright',
});
expect(result.result).toBe('clicked-result');
}, 30_000);
it('threads paramHints from recording through to the runtime', async () => {
// The recording fills a field with a known sentinel value; paramHints turn
// that into a params.{name} reference at compile time. At runtime we pass
// a different value for {name} and verify the page reflects the new value.
const html = `<!doctype html><html><body>
<input id="search" />
<button id="submit" onclick="document.getElementById('out').textContent='searched:' + document.getElementById('search').value">Submit</button>
<span id="out"></span>
</body></html>`;
const dataUrl = 'data:text/html,' + encodeURIComponent(html);
const recording: RecordedAction[] = [
action('goto', { url: dataUrl }),
action('fill', { selector: '#search', value: 'recorded-keyword' }),
action('click', { selector: '#submit' }),
action('getText', { selector: '#out' }),
];
const { source, meta } = compileScript({
recording,
description: 'paramHint plumbing',
paramHints: [
{ name: 'keyword', valueToReplace: 'recorded-keyword', type: 'string' },
],
});
// The compiler should have replaced the literal with params.keyword and
// surfaced the param spec in the frontmatter.
expect(source).toContain('.fill(params.keyword)');
expect(meta.params).toEqual([{ name: 'keyword', type: 'string' }]);
const scriptPath = writeTmpScript(source);
const result = await runUserScript({
scriptPath,
params: { keyword: 'replayed-keyword' },
runtime: 'playwright',
});
expect(result.result).toBe('searched:replayed-keyword');
}, 30_000);
});
+263
View File
@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { compileScript } from './script-compiler.js';
import type { CompileScriptOptions } from './script-compiler.js';
import type { RecordedAction } from '../engine/browser-recorder.js';
// ── helpers ───────────────────────────────────────────────────────────────────
function action(
type: RecordedAction['type'],
extras: Partial<Omit<RecordedAction, 'type' | 'ts'>> = {}
): RecordedAction {
return { type, ts: '2026-05-09T12:00:00Z', ...extras };
}
// ── tests ─────────────────────────────────────────────────────────────────────
describe('user-folder/script-compiler', () => {
// Test 1: Empty recording
it('produces a wrapper with no actions and returns undefined when recording is empty', () => {
const { source, meta } = compileScript({
recording: [],
description: 'Empty script',
});
expect(meta.description).toBe('Empty script');
expect(meta.params).toEqual([]);
// Body must contain try/finally wrapper
expect(source).toContain('async function main({ context, params })');
expect(source).toContain('const page = await context.newPage();');
expect(source).toContain('await page.close();');
// No return statement (undefined return)
expect(source).not.toContain('return __text');
expect(source).not.toContain('return __html');
});
// Test 2: Single goto
it('generates await page.goto for a goto action', () => {
const { source } = compileScript({
recording: [action('goto', { url: 'https://example.com' })],
description: 'Goto test',
});
expect(source).toContain("await page.goto('https://example.com');");
});
// Test 3: Click with stable selector
it('generates a locator click preserving the selector literally', () => {
const { source } = compileScript({
recording: [action('click', { selector: 'button[data-testid="submit"]' })],
description: 'Click test',
});
expect(source).toContain("await page.locator('button[data-testid=\"submit\"]').click();");
});
// Test 4: Fill with paramHint match
it('replaces fill value with params.{name} when paramHint matches', () => {
const { source, meta } = compileScript({
recording: [action('fill', { selector: '#username', value: 'admin' })],
description: 'Fill param test',
paramHints: [{ name: 'username', valueToReplace: 'admin', type: 'string' }],
});
expect(source).toContain("await page.locator('#username').fill(params.username);");
expect(meta.params).toEqual([{ name: 'username', type: 'string' }]);
});
// Test 5: Fill without paramHint match emits literal string
it('emits the literal string when no paramHint matches the fill value', () => {
const { source, meta } = compileScript({
recording: [action('fill', { selector: '#notes', value: 'hello world' })],
description: 'Fill literal test',
});
expect(source).toContain('await page.locator(\'#notes\').fill("hello world");');
expect(meta.params).toEqual([]);
});
// Test 6: Multiple fills with the same value — both use params.{name}
it('substitutes params.{name} in ALL fill calls sharing the same valueToReplace', () => {
const { source } = compileScript({
recording: [
action('fill', { selector: '#a', value: 'mytoken' }),
action('fill', { selector: '#b', value: 'mytoken' }),
],
description: 'Multiple fill test',
paramHints: [{ name: 'token', valueToReplace: 'mytoken', type: 'string' }],
});
expect(source).toContain("await page.locator('#a').fill(params.token);");
expect(source).toContain("await page.locator('#b').fill(params.token);");
});
// Test 7: Last action is getText — script returns __text
it('returns __text when the last action is getText', () => {
const { source } = compileScript({
recording: [
action('goto', { url: 'https://example.com' }),
action('getText', { selector: 'h1' }),
],
description: 'getText return test',
});
expect(source).toContain(
"const __text = await page.locator('h1').textContent();"
);
expect(source).toContain('return __text;');
});
// Test 8: Last action is dumpHtml — script returns __html
it('returns __html when the last action is dumpHtml', () => {
const { source } = compileScript({
recording: [action('dumpHtml', { selector: '#root' })],
description: 'dumpHtml return test',
});
expect(source).toContain(
"const __html = await page.locator('#root').evaluate(el => el.outerHTML);"
);
expect(source).toContain('return __html;');
});
// Test 9: Snapshot test — 5-action recording
it('snapshot: 5-action recording matches expected-compiled.js fixture', () => {
const fixturePath = join(
new URL('.', import.meta.url).pathname,
'../../tests/fixtures/scripts/expected-compiled.js'
);
const expected = readFileSync(fixturePath, 'utf-8');
const opts: CompileScriptOptions = {
recording: [
action('goto', { url: 'https://example.com/login' }),
action('fill', { selector: '[data-testid="username"]', value: 'admin' }),
action('fill', { selector: '[data-testid="password"]', value: 'secret' }),
action('click', { selector: 'button[type="submit"]' }),
action('getText', { selector: '[data-testid="dashboard"]' }),
],
description: 'Log in and grab the dashboard table',
sessionProfileId: 7,
recordingSource: 'rec-test.json',
paramHints: [
{ name: 'user', valueToReplace: 'admin', type: 'string' },
{ name: 'pass', valueToReplace: 'secret', type: 'string' },
],
};
const { source } = compileScript(opts);
expect(source).toBe(expected);
});
// Test 10a: frameChain with unique attr selectors → frameLocator chain
it('compiles a frameChain of unique selectors to a frameLocator chain', () => {
const { source } = compileScript({
recording: [
action('click', {
selector: '#inner-btn',
frameChain: [
{ selector: 'iframe[name="checkout"]' },
{ selector: 'iframe[id="card"]' },
],
}),
],
description: 'iframe click test',
});
expect(source).toContain(
`await page.frameLocator('iframe[name="checkout"]').frameLocator('iframe[id="card"]').locator('#inner-btn').click();`
);
expect(source).not.toContain('// TODO: iframe');
});
// Test 10b: frameChain with positional fallback → locator().nth().contentFrame()
it('compiles a positional frameChain entry to locator().nth().contentFrame()', () => {
const { source } = compileScript({
recording: [
action('fill', {
selector: 'input.card',
value: '4242',
frameChain: [{ selector: 'iframe', index: 1 }],
}),
],
description: 'positional iframe fill',
});
expect(source).toContain(
`await page.locator('iframe').nth(1).contentFrame().locator('input.card').fill("4242");`
);
});
// Test 10c: legacy string[] frameChain (backwards compat) → frameLocator chain
it('accepts legacy string[] frameChain (backwards compat)', () => {
const { source } = compileScript({
recording: [
action('click', { selector: '.btn', frameChain: ['iframe[name="legacy"]'] }),
],
description: 'legacy chain',
});
expect(source).toContain(
`await page.frameLocator('iframe[name="legacy"]').locator('.btn').click();`
);
});
// Test 10d: empty frameChain → unchanged page.locator(...)
it('compiles empty frameChain identically to no chain', () => {
const { source: withEmpty } = compileScript({
recording: [action('click', { selector: '.btn', frameChain: [] })],
description: 'empty',
});
const { source: withoutChain } = compileScript({
recording: [action('click', { selector: '.btn' })],
description: 'absent',
});
expect(withEmpty).toContain("await page.locator('.btn').click();");
expect(withoutChain).toContain("await page.locator('.btn').click();");
});
// Test 10e: dumpHtml with frameChain → applied
it('applies frameChain to dumpHtml', () => {
const { source } = compileScript({
recording: [
action('dumpHtml', {
selector: '.contents',
frameChain: [{ selector: 'iframe[name="cart"]' }],
}),
],
description: 'iframe dumpHtml',
});
expect(source).toContain(
`await page.frameLocator('iframe[name="cart"]').locator('.contents').evaluate(el => el.outerHTML);`
);
});
// Test 11: Only USED paramHints appear in frontmatter params (unused ones are dropped)
it('drops unused paramHints from frontmatter params', () => {
const { source, meta } = compileScript({
recording: [action('fill', { selector: '#q', value: 'hello' })],
description: 'Unused hint test',
paramHints: [
{ name: 'query', valueToReplace: 'hello', type: 'string' },
{ name: 'unused', valueToReplace: 'never-used', type: 'number' },
],
});
expect(meta.params).toHaveLength(1);
expect(meta.params[0].name).toBe('query');
expect(source).not.toContain('unused');
});
// Test 12: session_profile_id and recording_source round-trip
it('includes session_profile_id and recording_source in frontmatter when provided, omits them when not', () => {
const withBoth = compileScript({
recording: [],
description: 'Round-trip test',
sessionProfileId: 42,
recordingSource: 'rec-2026-05-09T12-34-56.json',
});
expect(withBoth.source).toContain('session_profile_id: 42');
expect(withBoth.source).toContain('recording_source: rec-2026-05-09T12-34-56.json');
expect(withBoth.meta.sessionProfileId).toBe(42);
expect(withBoth.meta.recordingSource).toBe('rec-2026-05-09T12-34-56.json');
const withoutBoth = compileScript({
recording: [],
description: 'No optional fields',
});
expect(withoutBoth.source).not.toContain('session_profile_id');
expect(withoutBoth.source).not.toContain('recording_source');
expect(withoutBoth.meta.sessionProfileId).toBeUndefined();
expect(withoutBoth.meta.recordingSource).toBeUndefined();
});
});
+204
View File
@@ -0,0 +1,204 @@
import type { RecordedAction, FrameChainEntry } from '../engine/browser-recorder.js';
import type { ParamSpec } from './frontmatter.js';
import { serializeScript } from './frontmatter.js';
import type { ScriptMeta } from './frontmatter.js';
// ── Public API ────────────────────────────────────────────────────────────────
export interface CompileScriptOptions {
recording: RecordedAction[];
description: string;
sessionProfileId?: number;
paramHints?: { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[];
recordingSource?: string;
}
export interface CompiledScript {
/** Frontmatter + body, ready to write to scripts/{name}.js */
source: string;
/** Frontmatter only (parsed back-shape for previewing). */
meta: ScriptMeta;
}
export interface CompiledScriptBody {
/** JS body only (no frontmatter). Pass to serializeScript({ frontmatter, body }). */
body: string;
/** Param specs that were actually used from paramHints (unused hints are dropped). */
paramSpecs: ParamSpec[];
}
// ── Quoting helpers ───────────────────────────────────────────────────────────
/** Single-quoted JS string literal (selector / simple URLs). */
function quoteSingle(s: string): string {
return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
}
/** Double-quoted JS string literal via JSON.stringify — safe for arbitrary strings. */
function quoteDouble(s: string): string {
return JSON.stringify(s);
}
/**
* Normalize a frameChain entry to the structured form.
* Legacy recordings may store `string[]`; we accept both shapes.
*/
function normalizeFrameEntry(e: FrameChainEntry | string): FrameChainEntry {
if (typeof e === 'string') return { selector: e };
return e;
}
/**
* Compose a Playwright FrameLocator / Page chain expression for `frameChain`.
*
* Returns 'page' when chain is empty. Each entry maps to either:
* - `.frameLocator(sel)` when only `selector` is set (unique attr selector)
* - `.locator(sel).nth(N).contentFrame()` when `index` is set (positional fallback)
*
* Both forms return a FrameLocator and compose, so a mixed chain works:
* page.frameLocator('iframe[name="x"]').locator('iframe').nth(0).contentFrame()
*/
function targetExpr(rawChain?: (FrameChainEntry | string)[]): string {
if (!rawChain || rawChain.length === 0) return 'page';
let expr = 'page';
for (const raw of rawChain) {
const entry = normalizeFrameEntry(raw);
if (entry.index !== undefined) {
expr = `${expr}.locator(${quoteSingle(entry.selector)}).nth(${entry.index}).contentFrame()`;
} else {
expr = `${expr}.frameLocator(${quoteSingle(entry.selector)})`;
}
}
return expr;
}
// ── Core compiler ─────────────────────────────────────────────────────────────
/**
* Compile just the JS body from a recording, returning the body string and the
* param specs that were actually used. Callers that need to control frontmatter
* timestamps (e.g. flushAndStageRecording) should use this instead of
* compileScript, then call serializeScript themselves.
*/
export function compileScriptBody(opts: CompileScriptOptions): CompiledScriptBody {
const { recording, paramHints = [] } = opts;
// Build a value→paramHint lookup (first-wins for duplicate valueToReplace)
const hintByValue = new Map<string, typeof paramHints[0]>();
for (const hint of paramHints) {
if (!hintByValue.has(hint.valueToReplace)) {
hintByValue.set(hint.valueToReplace, hint);
}
}
// Track which param names actually appear in the recording (preserving order of first use)
const usedParamNames = new Map<string, ParamSpec>(); // name → spec
// Generate action lines
const lines: string[] = [];
const lastIdx = recording.length - 1;
let returnVar: string | undefined;
for (let i = 0; i < recording.length; i++) {
const action = recording[i];
const isLast = i === lastIdx;
switch (action.type) {
case 'goto': {
const url = action.url ?? '';
lines.push(` await page.goto(${quoteSingle(url)});`);
break;
}
case 'click': {
const selector = action.selector ?? '';
const target = targetExpr(action.frameChain);
lines.push(` await ${target}.locator(${quoteSingle(selector)}).click();`);
break;
}
case 'fill': {
const selector = action.selector ?? '';
const value = action.value ?? '';
const hint = hintByValue.get(value);
let valueExpr: string;
if (hint) {
valueExpr = `params.${hint.name}`;
if (!usedParamNames.has(hint.name)) {
usedParamNames.set(hint.name, { name: hint.name, type: hint.type });
}
} else {
valueExpr = quoteDouble(value);
}
const target = targetExpr(action.frameChain);
lines.push(` await ${target}.locator(${quoteSingle(selector)}).fill(${valueExpr});`);
break;
}
case 'wait': {
const ms = action.ms ?? 0;
lines.push(` await page.waitForTimeout(${ms});`);
break;
}
case 'screenshot': {
const value = action.value ?? '';
lines.push(` await page.screenshot({ path: ${quoteDouble(value)} });`);
break;
}
case 'getText': {
const selector = action.selector ?? '';
const target = targetExpr(action.frameChain);
lines.push(` const __text = await ${target}.locator(${quoteSingle(selector)}).textContent();`);
if (isLast) returnVar = '__text';
break;
}
case 'dumpHtml': {
const selector = action.selector ?? '';
const target = targetExpr(action.frameChain);
lines.push(
` const __html = await ${target}.locator(${quoteSingle(selector)}).evaluate(el => el.outerHTML);`
);
if (isLast) returnVar = '__html';
break;
}
}
}
// Build body
const bodyLines: string[] = [
'async function main({ context, params }) {',
' const page = await context.newPage();',
' try {',
...lines,
];
if (returnVar) {
bodyLines.push(` return ${returnVar};`);
}
bodyLines.push(' } finally {', ' await page.close();', ' }', '}', 'module.exports = main;', '');
const body = bodyLines.join('\n');
const paramSpecs: ParamSpec[] = Array.from(usedParamNames.values());
return { body, paramSpecs };
}
export function compileScript(opts: CompileScriptOptions): CompiledScript {
const { description, sessionProfileId, recordingSource } = opts;
const { body, paramSpecs } = compileScriptBody(opts);
// Build frontmatter meta
const meta: ScriptMeta = { description, params: paramSpecs };
if (sessionProfileId !== undefined) meta.sessionProfileId = sessionProfileId;
if (recordingSource !== undefined) meta.recordingSource = recordingSource;
// Serialize using Task 2.1's serializeScript
const source = serializeScript({ frontmatter: meta, body });
return { source, meta };
}
+195
View File
@@ -0,0 +1,195 @@
/**
* script-orchestrator.ts
*
* Shared "resolve a user script 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
* does not need to spin up an LLM agent loop.
*
* Responsibilities:
* - Path resolution under data/users/{userId}/{scripts,browser-macros}/ with
* traversal protection (delegated to resolveUserSubdir).
* - Frontmatter parsing for browser-macros to find session_profile_id.
* - Decrypting + loading Playwright storageState for the owning user.
* - Calling runUserScript() with the right runtime.
*
* Intentionally NOT responsible for:
* - Config gating (tools.user_scripts_enabled) — callers decide.
* - Tool-result formatting / recorder side effects — those stay in the
* LLM tool wrapper since they only make sense in an agent context.
*/
import { existsSync, readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { resolveUserSubdir } from './paths.js';
import { parseScript } from './frontmatter.js';
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 interface ResolveScriptResult {
scriptPath: string;
subdir: ScriptSubdir;
runtime: ScriptRuntime;
}
/**
* Resolve a script name to its on-disk path. When kind is omitted, scripts/
* is searched first, then browser-macros/.
*/
export function resolveScriptForKind(
rootDir: string,
userId: string,
scriptName: string,
kind: ScriptKind | undefined,
): 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' };
}
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/)` };
}
export interface ResolveAndRunOptions {
rootDir: string;
userId: string;
/** Script name with or without `.js` extension. */
name: string;
params: Record<string, unknown>;
/** If omitted, scripts/ is tried first, then browser-macros/. */
kind?: ScriptKind;
/** Required only when the resolved script is a browser-macro that declares session_profile_id. */
sessRepo?: BrowserSessionRepo;
masterKeyPath?: string;
/** Child process timeout. Default 60_000. */
timeoutMs?: number;
/** Launch Chromium headless (browser-macros only). Default true. */
headless?: boolean;
}
export interface ResolveAndRunSuccess {
ok: true;
result: unknown;
logs: string[];
durationMs: number;
subdir: ScriptSubdir;
runtime: ScriptRuntime;
scriptPath: string;
}
export interface ResolveAndRunFailure {
ok: false;
error: string;
/** When the failure was during script execution (not resolution), include what we know. */
subdir?: ScriptSubdir;
scriptPath?: string;
durationMs?: number;
}
export type ResolveAndRunResult = ResolveAndRunSuccess | ResolveAndRunFailure;
/**
* End-to-end: resolve a user-owned script by name, hydrate its session if it is
* a browser-macro that needs one, and execute it. Never throws — failures are
* returned as `{ ok: false, error }`.
*/
export async function resolveAndRunUserScript(opts: ResolveAndRunOptions): Promise<ResolveAndRunResult> {
const { rootDir, userId, params, kind, sessRepo, masterKeyPath, timeoutMs, headless } = opts;
const name = opts.name.endsWith('.js') ? opts.name : `${opts.name}.js`;
const resolved = resolveScriptForKind(rootDir, userId, name, kind);
if ('error' in resolved) {
return { ok: false, error: resolved.error };
}
const { scriptPath, subdir, runtime } = resolved;
let storageState: object | undefined;
if (subdir === 'browser-macros') {
let sessionProfileId: number | undefined;
try {
const source = readFileSync(scriptPath, 'utf-8');
sessionProfileId = parseScript(source).frontmatter.sessionProfileId;
} catch (err) {
return {
ok: false,
error: `failed to parse script frontmatter: ${(err as Error).message}`,
subdir,
scriptPath,
};
}
if (sessionProfileId !== undefined) {
if (!sessRepo || !masterKeyPath) {
return {
ok: false,
error:
'session profile required but BrowserSessionRepo not configured. ' +
'Falling back is recommended — try BrowseWeb manually.',
subdir,
scriptPath,
};
}
const sessionResult = await loadSessionStateForUser(
{ sessRepo, masterKeyPath },
userId,
sessionProfileId,
);
if (!sessionResult.ok) {
return { ok: false, error: sessionResult.error.message, subdir, scriptPath };
}
storageState = sessionResult.storageState;
}
}
const start = Date.now();
try {
const runResult = await runUserScript({
scriptPath,
params,
runtime,
storageState,
timeoutMs: timeoutMs ?? 60_000,
...(headless !== undefined ? { headless } : {}),
});
return {
ok: true,
result: runResult.result,
logs: runResult.logs,
durationMs: runResult.durationMs,
subdir,
runtime,
scriptPath,
};
} catch (err) {
return {
ok: false,
error: (err as Error).message,
subdir,
scriptPath,
durationMs: Date.now() - start,
};
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* script-runner-child.ts
*
* Runs as a child process spawned by script-runner.ts.
* Reads one JSON line from stdin, and branches on the `runtime` field:
*
* runtime === 'playwright':
* Launches Chromium via Playwright, calls main({ context, params }).
*
* runtime === 'plain':
* No Chromium. Calls main({ params }) directly.
*
* Protocol:
* stdin: { scriptPath, params, runtime, storageState?, headless? }
* stdout: { type: 'result', value: <return value> }
* { type: 'log', text: <line> }
* exit 0 on success, exit 1 on error (stack trace on stderr)
*/
import { createRequire } from 'module';
async function readStdinAll(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf-8');
}
// ── Console interception helpers ──────────────────────────────────────────────
function emitLog(text: string) {
process.stdout.write(JSON.stringify({ type: 'log', text }) + '\n');
}
function patchConsole() {
const originalLog = console.log.bind(console);
const originalInfo = console.info.bind(console);
const originalWarn = console.warn.bind(console);
const originalError = console.error.bind(console);
console.log = (...args: unknown[]) => emitLog(args.map(String).join(' '));
console.info = (...args: unknown[]) => emitLog(args.map(String).join(' '));
console.warn = (...args: unknown[]) => emitLog('[warn] ' + args.map(String).join(' '));
console.error = (...args: unknown[]) => emitLog('[error] ' + args.map(String).join(' '));
return function restoreConsole() {
console.log = originalLog;
console.info = originalInfo;
console.warn = originalWarn;
console.error = originalError;
};
}
// ── Safe result serialization ─────────────────────────────────────────────────
function emitResult(result: unknown): void {
let resultMessage: string;
try {
resultMessage = JSON.stringify({ type: 'result', value: result });
} catch (e) {
resultMessage = JSON.stringify({
type: 'result',
value: null,
serializationError: (e instanceof Error ? e.message : String(e)),
});
}
process.stdout.write(resultMessage + '\n');
}
// ── Load user script ──────────────────────────────────────────────────────────
function loadUserFn(scriptPath: string): (...args: unknown[]) => unknown {
const require = createRequire(import.meta.url);
const userScript = require(scriptPath) as unknown;
let fn: ((...args: unknown[]) => unknown) | undefined;
if (typeof userScript === 'function') {
fn = userScript as (...args: unknown[]) => unknown;
} else if (userScript !== null && typeof userScript === 'object') {
const mod = userScript as Record<string, unknown>;
if (typeof mod['default'] === 'function') {
fn = mod['default'] as (...args: unknown[]) => unknown;
} else if (typeof mod['main'] === 'function') {
fn = mod['main'] as (...args: unknown[]) => unknown;
}
}
if (typeof fn !== 'function') {
throw new Error(
'script must export a function (module.exports = fn, or named exports .default/.main)'
);
}
return fn;
}
// ── Plain runtime ─────────────────────────────────────────────────────────────
async function runPlain(
scriptPath: string,
params: Record<string, unknown>,
): Promise<void> {
const fn = loadUserFn(scriptPath);
const restore = patchConsole();
try {
const result = await fn({ params });
emitResult(result);
} finally {
restore();
}
}
// ── Playwright runtime ────────────────────────────────────────────────────────
async function runPlaywright(
scriptPath: string,
params: Record<string, unknown>,
storageState: object | undefined,
headless: boolean,
): Promise<void> {
// Dynamic import so that the chromium binary is only loaded for playwright runtime.
const { chromium } = await import('playwright');
const fn = loadUserFn(scriptPath);
const restore = patchConsole();
const browser = await chromium.launch({ headless });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const contextOptions = storageState ? { storageState: storageState as any } : {};
const context = await browser.newContext(contextOptions);
const result = await fn({ context, params });
emitResult(result);
} finally {
restore();
await browser.close();
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
const raw = await readStdinAll();
const input = JSON.parse(raw) as {
scriptPath: string;
params: Record<string, unknown>;
runtime: 'plain' | 'playwright';
storageState?: object;
headless?: boolean;
};
const { scriptPath, params, runtime, storageState, headless = true } = input;
if (runtime === 'plain') {
await runPlain(scriptPath, params);
} else {
await runPlaywright(scriptPath, params, storageState, headless);
}
}
main().catch((e: unknown) => {
const err = e instanceof Error ? e : new Error(String(e));
process.stderr.write((err.stack ?? err.message) + '\n');
process.exit(1);
});
+376
View File
@@ -0,0 +1,376 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, afterEach } from 'vitest';
import { runUserScript } from './script-runner.js';
// Resolve paths relative to this test file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Fixtures are at <repo>/tests/fixtures/scripts/
const fixturesDir = path.resolve(__dirname, '../../tests/fixtures/scripts');
function fixture(name: string) {
return path.join(fixturesDir, name);
}
const browserMacroFixturesDir = path.resolve(__dirname, '../../tests/fixtures/browser-macros');
function browserMacroFixture(name: string) {
return path.join(browserMacroFixturesDir, name);
}
// Playwright E2E is opt-out (cheap to skip when chromium isn't installed in CI).
// Set SKIP_PLAYWRIGHT_E2E=1 to bypass these tests; they run by default since
// dev environments and CI both ship the chromium binary via @playwright/test.
const skipPlaywright = process.env['SKIP_PLAYWRIGHT_E2E'] === '1';
// Track temp files for cleanup
const tmpFiles: string[] = [];
afterEach(() => {
for (const f of tmpFiles) {
try { fs.unlinkSync(f); } catch { /* already gone */ }
}
tmpFiles.length = 0;
});
/**
* Write a temporary script file and return its path.
* The file is registered for cleanup in afterEach.
*/
function writeTmpScript(content: string): string {
const tmpPath = path.join(os.tmpdir(), `test-script-${process.pid}-${Date.now()}.js`);
fs.writeFileSync(tmpPath, content, 'utf-8');
tmpFiles.push(tmpPath);
return tmpPath;
}
// ─── Test 1: return-42.js → result is 42 ──────────────────────────────────────
describe('script-runner', () => {
it('resolves with result=42 for return-42.js (plain runtime)', async () => {
const res = await runUserScript({
scriptPath: fixture('return-42.js'),
params: {},
runtime: 'plain',
});
expect(res.result).toBe(42);
expect(typeof res.durationMs).toBe('number');
expect(Array.isArray(res.logs)).toBe(true);
}, 10_000); // plain runtime is fast — no Chromium launch
// ─── Test 2: timeout.js → rejects after ~200ms ──────────────────────────────
it('rejects with timeout error when script hangs (plain runtime)', async () => {
await expect(
runUserScript({
scriptPath: fixture('timeout.js'),
params: {},
runtime: 'plain',
timeoutMs: 500,
})
).rejects.toThrow(/timeout/);
}, 10_000);
// ─── Test 3: throws.js → rejects with exit code error ──────────────────────
it('rejects with exit-code error and includes stderr for throws.js (plain runtime)', async () => {
await expect(
runUserScript({
scriptPath: fixture('throws.js'),
params: {},
runtime: 'plain',
})
).rejects.toThrow(/exited code 1.*boom from user script/s);
}, 10_000);
// ─── Test 4: param type mismatch → synchronous rejection ───────────────────
it('rejects synchronously on param type mismatch (no browser spawn)', async () => {
// Script with a frontmatter declaring x: number
const scriptContent = `---
description: "Echo params"
params:
- name: x
type: number
---
async function main({ params }) { return params.x; }
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
// Passing a string where number is expected
await expect(
runUserScript({
scriptPath,
params: { x: 'not a number' },
runtime: 'plain',
})
).rejects.toThrow(/param x: expected number/);
});
// ─── Test 5: param default applied ─────────────────────────────────────────
it('applies param defaults when optional param is omitted (plain runtime)', async () => {
// Script with a boolean param that has default: false
const scriptContent = `---
description: "Echo verbose"
params:
- name: verbose
type: boolean
default: false
---
async function main({ params }) { return params.verbose; }
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
const res = await runUserScript({
scriptPath,
params: {}, // no 'verbose' key
runtime: 'plain',
});
expect(res.result).toBe(false);
}, 10_000);
// ─── Test 6: stderr captured into logs[] ────────────────────────────────────
it('captures stderr into logs[] (plain runtime)', async () => {
const res = await runUserScript({
scriptPath: fixture('logs-to-stderr.js'),
params: {},
runtime: 'plain',
});
expect(res.result).toBe('done');
expect(res.logs.some(l => l.includes('hello from stderr'))).toBe(true);
expect(res.logs.some(l => l.includes('second line'))).toBe(true);
}, 10_000);
// ─── Test 7: bad default type → rejected with /invalid default/ ─────────────
it('rejects when frontmatter default has wrong type', async () => {
const scriptContent = `---
description: "Bad default"
params:
- name: count
type: number
default: "not-a-number"
---
async function main({ params }) { return params.count; }
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/invalid default/);
});
// ─── Test 8: stdout cap → rejects with /stdout exceeded/ ────────────────────
it('rejects when script stdout exceeds 1 MB (plain runtime)', async () => {
// Emit 2 MB of data synchronously then return
const scriptContent = `---
description: "Stdout flood"
params: []
---
async function main() {
// Write ~2 MB of data to stdout in one shot via process.stdout.write
// (bypasses our console.log interception — tests raw stdout cap)
const chunk = Buffer.alloc(1024, 65); // 'A' * 1024
for (let i = 0; i < 2048; i++) process.stdout.write(chunk);
return 'done';
}
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/stdout exceeded/);
}, 30_000);
// ─── Test 9: circular object → serializationError, result is null ───────────
it('returns null result with serializationError for circular objects (plain runtime)', async () => {
const res = await runUserScript({
scriptPath: fixture('circular.js'),
params: {},
runtime: 'plain',
});
expect(res.result).toBeNull();
expect(typeof res.serializationError).toBe('string');
expect(res.serializationError).toMatch(/circular/i);
}, 10_000);
// ─── Test 10a: plain runtime + session_profile_id → synchronous rejection ───
it('rejects synchronously when session_profile_id is set on plain runtime', async () => {
const scriptContent = `---
description: "Browser macro mistakenly placed in scripts/"
session_profile_id: 7
---
async function main() { return 'should never run'; }
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/session_profile_id.*plain/i);
});
// ─── Test 10b: plain runtime + no session_profile_id → no rejection ─────────
it('does not reject when session_profile_id is absent on plain runtime', async () => {
const scriptContent = `---
description: "Normal plain script"
---
async function main() { return 'ok'; }
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
const res = await runUserScript({ scriptPath, params: {}, runtime: 'plain' });
expect(res.result).toBe('ok');
}, 10_000);
// ─── Test 10: plain runtime does not launch Chromium ────────────────────────
it('plain runtime completes quickly (no Chromium launch overhead)', async () => {
const scriptContent = `---
description: "Quick plain script"
params: []
---
async function main({ params }) { return 'fast'; }
module.exports = main;
`;
const scriptPath = writeTmpScript(scriptContent);
const start = Date.now();
const res = await runUserScript({ scriptPath, params: {}, runtime: 'plain' });
const elapsed = Date.now() - start;
expect(res.result).toBe('fast');
// Plain runtime should complete well under 5 seconds (Chromium launch is ~2-5s)
expect(elapsed).toBeLessThan(5_000);
}, 10_000);
});
// ── --permission sandbox (plain runtime) ─────────────────────────────────────
describe('script-runner: --permission sandbox (plain runtime)', () => {
it('blocks child_process.spawn (no --allow-child-process flag is passed)', async () => {
const scriptPath = writeTmpScript(`
async function main() {
const cp = require('child_process');
cp.spawnSync('/bin/echo', ['x']);
return 'should-not-reach-here';
}
module.exports = main;
`);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/permission/i);
});
it('steers child_process denials to the Bash tool (python footgun hint)', async () => {
// The recurring failure: a plain script tries to shell out to python.
// --permission denies child_process; the error must point the caller at
// Bash (which has the pre-baked pip env) instead of leaving them stuck.
const scriptPath = writeTmpScript(`
async function main() {
const cp = require('child_process');
cp.spawnSync('python3', ['-c', 'print(1)']);
return 'should-not-reach-here';
}
module.exports = main;
`);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/use the Bash tool/);
});
it('blocks worker_threads (no --allow-worker flag is passed)', async () => {
const scriptPath = writeTmpScript(`
async function main() {
const { Worker } = require('worker_threads');
new Worker('console.log("x")', { eval: true });
return 'should-not-reach-here';
}
module.exports = main;
`);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/permission/i);
});
it('blocks reading files outside the staging tmp dir', async () => {
const scriptPath = writeTmpScript(`
async function main() {
const fs = require('fs');
// /etc/passwd is allowed to exist but should be unreadable under --permission
// because we only --allow-fs-read=<tmpdir,scriptDir>.
fs.readFileSync('/etc/passwd', 'utf-8');
return 'should-not-reach-here';
}
module.exports = main;
`);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'plain' })
).rejects.toThrow(/permission|ERR_ACCESS_DENIED/i);
});
it('still lets the script return values, console.log, and use tmp-dir fs', async () => {
const scriptPath = writeTmpScript(`
async function main({ params }) {
console.log('logged from inside the sandbox');
return { ok: true, n: params.n };
}
module.exports = main;
`);
// The runner declares the param 'n' in its compiled wrapper, so the plain
// path validates against the script's own frontmatter — we have none here,
// so pass empty params.
const res = await runUserScript({ scriptPath, params: {}, runtime: 'plain' });
expect(res.result).toMatchObject({ ok: true });
expect(res.logs.some(l => l.includes('logged from inside the sandbox'))).toBe(true);
});
});
// ── Playwright runtime E2E ────────────────────────────────────────────────────
describe.skipIf(skipPlaywright)('script-runner: playwright runtime (E2E)', () => {
it('navigates a data: URL, clicks a button, and returns textContent', async () => {
const res = await runUserScript({
scriptPath: browserMacroFixture('click-and-read.js'),
params: { title: 'hello-from-fixture' },
runtime: 'playwright',
});
expect(res.result).toBe('revealed:hello-from-fixture');
expect(typeof res.durationMs).toBe('number');
expect(res.durationMs).toBeGreaterThan(0);
}, 30_000);
it('runs a browser-macro without session frontmatter', async () => {
const res = await runUserScript({
scriptPath: browserMacroFixture('no-frontmatter.js'),
params: {},
runtime: 'playwright',
});
expect(res.result).toBe('hello');
}, 30_000);
it('surfaces an error when the macro throws inside the browser context', async () => {
const scriptPath = writeTmpScript(`
async function main({ context }) {
const page = await context.newPage();
try {
await page.goto('data:text/html,<body>x</body>');
throw new Error('intentional inside playwright');
} finally {
await page.close();
}
}
module.exports = main;
`);
await expect(
runUserScript({ scriptPath, params: {}, runtime: 'playwright' })
).rejects.toThrow(/intentional inside playwright/);
}, 30_000);
});
+446
View File
@@ -0,0 +1,446 @@
/**
* script-runner.ts
*
* Parent process that:
* 1. Reads and parses the user script's frontmatter.
* 2. Validates params against the declared spec (type-checks, defaults).
* 3. Strips frontmatter, writes the body to a temp .cjs file.
* 4. Spawns a Node child process (script-runner-child.js) with the temp path.
* 5. Sends input via stdin, collects JSON lines from stdout, enforces timeout.
* 6. Cleans up the temp file after child exits.
*/
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { parseScript } from './frontmatter.js';
import type { ParamSpec } from './frontmatter.js';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface RunScriptOptions {
/** Absolute path to the .js file (may have YAML frontmatter). */
scriptPath: string;
/** Runtime parameter values supplied by the caller. */
params: Record<string, unknown>;
/**
* 'plain' — plain Node.js, no Chromium, main({ params }) signature.
* 'playwright' — launches Chromium via Playwright, main({ context, params }) signature.
*/
runtime: 'plain' | 'playwright';
/** Playwright storageState for authenticated sessions; undefined = no session.
* Only meaningful when runtime === 'playwright'. Ignored (with a warn) otherwise. */
storageState?: object;
/** Milliseconds before the child is killed. Default 30_000. */
timeoutMs?: number;
/** Launch browser headless. Default true. Only meaningful for runtime === 'playwright'. */
headless?: boolean;
}
export interface RunScriptResult {
/** Whatever the user script's main() returned. */
result: unknown;
/** Lines collected from the child's console.log / structured log lines. */
logs: string[];
/** Wall-clock time from spawn to completion, in ms. */
durationMs: number;
/** Set when the child's result could not be JSON-serialized (e.g. circular reference, BigInt). */
serializationError?: string;
}
// ── Param validation ──────────────────────────────────────────────────────────
/**
* Validates and normalises caller-supplied params against the frontmatter spec.
* Returns a new params object with defaults applied.
* Throws synchronously on any mismatch.
*
* Exported because RenderUserTemplate reuses the same param spec semantics
* (scripts/browser-macros/templates all share the frontmatter params shape).
*/
export function validateAndApplyDefaults(
spec: ParamSpec[],
params: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
// Check for extra params not in the spec
const specNames = new Set(spec.map((p) => p.name));
for (const key of Object.keys(params)) {
if (!specNames.has(key)) {
throw new Error(`param ${key}: not declared in script frontmatter`);
}
}
for (const p of spec) {
const { name, type } = p;
if (Object.prototype.hasOwnProperty.call(params, name)) {
// Param was provided — type-check it
const value = params[name];
// eslint-disable-next-line valid-typeof
if (typeof value !== type) {
throw new Error(`param ${name}: expected ${type}, got ${typeof value}`);
}
result[name] = value;
} else if (Object.prototype.hasOwnProperty.call(p, 'default')) {
// Use declared default — type-check it same as caller-provided params
const defaultVal = p.default;
// eslint-disable-next-line valid-typeof
if (typeof defaultVal !== type) {
throw new Error(
`param ${name}: invalid default value (expected ${type}, got ${typeof defaultVal})`
);
}
result[name] = defaultVal;
} else {
// Required param missing
throw new Error(`param ${name}: required but not provided`);
}
}
return result;
}
// ── Child path resolution ─────────────────────────────────────────────────────
/**
* Resolves the absolute path to script-runner-child.js.
*
* During development (vitest/tsx): import.meta.url points to the .ts source in
* src/user-folder/. The compiled child is at dist/user-folder/script-runner-child.js.
*
* After `tsc` (production): both files are in dist/user-folder/ and the sibling
* path resolves directly.
*
* Strategy:
* 1. Try sibling in the same directory (production path).
* 2. Fall back to the dist/ equivalent (development / vitest path).
*/
function resolveChildPath(): string {
const thisFile = fileURLToPath(import.meta.url);
const thisDir = dirname(thisFile);
// Candidate 1: sibling .js in same directory (works after tsc)
const sibling = join(thisDir, 'script-runner-child.js');
if (existsSync(sibling)) return sibling;
// Candidate 2: compiled dist/ version (works when vitest runs from src/)
// Walk up until we find the project root (contains package.json), then go to dist/
let dir = thisDir;
let distChild = '';
for (let i = 0; i < 10; i++) {
const pkg = join(dir, 'package.json');
if (existsSync(pkg)) {
distChild = join(dir, 'dist', 'user-folder', 'script-runner-child.js');
if (existsSync(distChild)) return distChild;
break;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(
`script-runner-child.js not found. Run \`npm run build\` to compile it. Searched: ${sibling}, ${distChild}`
);
}
// ── Main export ───────────────────────────────────────────────────────────────
export async function runUserScript(opts: RunScriptOptions): Promise<RunScriptResult> {
const {
scriptPath,
params,
runtime,
storageState,
timeoutMs = 30_000,
headless = true,
} = opts;
if (runtime === 'plain' && storageState !== undefined) {
// eslint-disable-next-line no-console
console.warn('[script-runner] storageState was provided for runtime=plain; it will be ignored.');
}
// 1. Parse frontmatter
const source = readFileSync(scriptPath, 'utf-8');
const parsed = parseScript(source);
// 1a. Reject session_profile_id on plain runtime — it only applies to
// browser-macros (playwright). Silently ignoring it lets a user copy a
// browser-macros script into scripts/ and wonder why the session is missing.
if (runtime === 'plain' && parsed.frontmatter.sessionProfileId !== undefined) {
throw new Error(
`session_profile_id is set in frontmatter but runtime is "plain". ` +
`session_profile_id only applies to browser-macros (playwright runtime). ` +
`If this is a browser automation script, move it to user-folder/browser-macros/; ` +
`otherwise remove the session_profile_id frontmatter key.`
);
}
// 2. Validate params (throws synchronously before any subprocess is spawned)
const resolvedParams = validateAndApplyDefaults(parsed.frontmatter.params, params);
// 3. Write body to a temp .cjs file (so Node's require() works regardless of
// the project's "type": "module" setting and ignores the leading frontmatter)
const tmpPath = join(tmpdir(), `script-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}.cjs`);
writeFileSync(tmpPath, parsed.body, 'utf-8');
const startMs = Date.now();
const cleanup = () => { try { unlinkSync(tmpPath); } catch {} };
process.once('exit', cleanup);
try {
return await spawnChild({
childScriptPath: resolveChildPath(),
tmpPath,
resolvedParams,
runtime,
storageState: runtime === 'playwright' ? storageState : undefined,
headless,
timeoutMs,
startMs,
});
} finally {
// Always clean up temp file
process.removeListener('exit', cleanup);
cleanup();
}
}
// ── Spawn helper ──────────────────────────────────────────────────────────────
interface SpawnOpts {
childScriptPath: string;
tmpPath: string;
resolvedParams: Record<string, unknown>;
runtime: 'plain' | 'playwright';
storageState?: object;
headless: boolean;
timeoutMs: number;
startMs: number;
}
// ── Output caps ───────────────────────────────────────────────────────────────
const MAX_STDOUT_BYTES = 1_000_000; // 1 MB
const MAX_STDERR_BYTES = 200_000; // 200 KB
function spawnChild(opts: SpawnOpts): Promise<RunScriptResult> {
const { childScriptPath, tmpPath, resolvedParams, runtime, storageState, headless, timeoutMs, startMs } = opts;
return new Promise((resolve, reject) => {
// ── Minimal env: deny child access to API keys, DB passwords, etc. ────────
// Node's Permissions Model (--permission) conflicts with Playwright, so we
// scrub the environment instead. Only variables needed by Node/Playwright
// are forwarded.
const minimalEnv: NodeJS.ProcessEnv = {
PATH: process.env['PATH'],
HOME: process.env['HOME'],
TMPDIR: process.env['TMPDIR'],
TMP: process.env['TMP'],
LANG: process.env['LANG'],
NODE_ENV: process.env['NODE_ENV'],
// Playwright needs this to locate downloaded browser binaries
PLAYWRIGHT_BROWSERS_PATH: process.env['PLAYWRIGHT_BROWSERS_PATH'],
};
// Remove entries whose value is undefined (Object.fromEntries skips nothing,
// but passing undefined values to spawn causes issues on some platforms).
const env = Object.fromEntries(
Object.entries(minimalEnv).filter(([, v]) => v !== undefined)
) as NodeJS.ProcessEnv;
// ── Spawn with restricted CWD and detached process group ─────────────────
// detached: true gives the child its own process group so we can kill the
// entire group (including Chromium descendants) on timeout or overflow.
//
// For plain runtime we additionally engage Node's Permissions Model
// (--permission), which blocks child_process, worker threads, native
// addons, and unscoped FS/net access. A plain script that tries to
// require('child_process').spawn(...) now fails at runtime instead of
// silently exfiltrating data.
//
// playwright runtime cannot use --permission: Chromium spawn, native
// bindings, and outbound HTTPS all require unrestricted child_process /
// addons / network. We rely on env scrub + container-level isolation
// (when present) for browser-macros.
const nodeArgs: string[] = [];
if (runtime === 'plain') {
const scriptDir = dirname(childScriptPath);
const tmpRoot = tmpdir();
nodeArgs.push(
'--permission',
`--allow-fs-read=${scriptDir}`,
`--allow-fs-read=${tmpRoot}`,
`--allow-fs-write=${tmpRoot}`,
);
}
nodeArgs.push(childScriptPath);
const child = spawn(process.execPath, nodeArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env,
cwd: tmpdir(),
detached: true,
});
// Send input via stdin
const input = JSON.stringify({
scriptPath: tmpPath,
params: resolvedParams,
runtime,
storageState,
headless,
});
child.stdin.write(input + '\n');
child.stdin.end();
// Collect stdout lines (structured JSON)
let stdoutBuf = '';
const logs: string[] = [];
let result: unknown = undefined;
let hasResult = false;
let serializationError: string | undefined;
let stdoutBytes = 0;
let rejected = false;
child.stdout.on('data', (chunk: Buffer) => {
if (rejected) return;
stdoutBytes += chunk.length;
if (stdoutBytes > MAX_STDOUT_BYTES) {
rejected = true;
if (child.pid) {
// Kill the entire process group (Chromium descendants included)
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
setTimeout(() => { try { process.kill(-child.pid!, 'SIGKILL'); } catch {} }, 1_000);
} else {
child.kill('SIGTERM');
}
reject(new Error(`script stdout exceeded ${MAX_STDOUT_BYTES} bytes`));
return;
}
stdoutBuf += chunk.toString('utf-8');
const lines = stdoutBuf.split('\n');
stdoutBuf = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line) as {
type: string;
value?: unknown;
text?: string;
serializationError?: string;
};
if (msg.type === 'result') {
result = msg.value;
hasResult = true;
if (typeof msg.serializationError === 'string') {
serializationError = msg.serializationError;
}
} else if (msg.type === 'log' && typeof msg.text === 'string') {
logs.push(msg.text);
}
} catch {
// Non-JSON line on stdout — treat as a log entry
logs.push(line);
}
}
});
// Collect stderr into logs[]
let stderrBuf = '';
let stderrBytes = 0;
child.stderr.on('data', (chunk: Buffer) => {
if (rejected) return;
stderrBytes += chunk.length;
if (stderrBytes > MAX_STDERR_BYTES) {
rejected = true;
if (child.pid) {
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
setTimeout(() => { try { process.kill(-child.pid!, 'SIGKILL'); } catch {} }, 1_000);
} else {
child.kill('SIGTERM');
}
reject(new Error(`script stderr exceeded ${MAX_STDERR_BYTES} bytes`));
return;
}
stderrBuf += chunk.toString('utf-8');
});
// Timeout enforcement: kill the whole process group so Chromium descendants
// spawned by Playwright don't survive the timeout.
let timedOut = false;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) {
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
killTimer = setTimeout(() => {
try { process.kill(-child.pid!, 'SIGKILL'); } catch {}
}, 1_000);
} else {
child.kill('SIGTERM');
killTimer = setTimeout(() => { child.kill('SIGKILL'); }, 1_000);
}
}, timeoutMs);
child.on('close', (code) => {
clearTimeout(timeoutHandle);
if (killTimer !== undefined) clearTimeout(killTimer);
if (rejected) return; // already rejected (cap or timeout before close)
if (timedOut) {
reject(new Error(`script timeout: exceeded ${timeoutMs}ms`));
return;
}
const durationMs = Date.now() - startMs;
if (code !== 0) {
const stderrTail = stderrBuf.slice(-2000).trim();
// Common footgun: a plain Node script tries to child_process.spawn
// python (or anything), but plain runtime is sandboxed with Node's
// --permission model, which denies child_process. Detect the denial
// signature and steer the caller to the Bash tool, which has the
// pre-baked pip environment and is the supported way to run Python.
const permissionDenied =
runtime === 'plain' &&
/ERR_ACCESS_DENIED|Access to this API has been restricted|permission/i.test(stderrTail);
const hint = permissionDenied
? ' — plain scripts run under Node --permission (no child_process). ' +
'To run Python or shell out, use the Bash tool instead (pip packages are pre-baked); ' +
'do not wrap it in a Node script.'
: '';
reject(new Error(`script exited code ${code}: ${stderrTail}${hint}`));
return;
}
if (!hasResult) {
// Script exited 0 but never wrote a result — treat as undefined
result = undefined;
}
// Route stderr into logs[] on success path
if (stderrBuf.trim()) {
for (const line of stderrBuf.split('\n')) {
if (line.trim()) logs.push(line);
}
}
resolve({ result, logs, durationMs, serializationError });
});
child.on('error', (err) => {
clearTimeout(timeoutHandle);
if (killTimer !== undefined) clearTimeout(killTimer);
if (!rejected) reject(err);
});
});
}
+92
View File
@@ -0,0 +1,92 @@
/**
* session-loader.ts
*
* Shared helper for decrypting a browser session storageState blob.
* Used by both:
* - src/engine/tools/user-folder.ts (RunUserScript tool)
* - src/bridge/user-folder-api.ts (POST /scripts/:name/run endpoint)
*/
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { initMasterKey, decryptUserDek, decryptStateBlob } from '../crypto/sessions.js';
import { logger } from '../logger.js';
interface SessionLoaderDeps {
sessRepo: BrowserSessionRepo;
masterKeyPath: string;
}
export type SessionLoadError =
| { kind: 'profile_not_found'; message: string }
| { kind: 'profile_not_active'; message: string }
| { kind: 'dek_not_found'; message: string }
| { kind: 'decrypt_error'; message: string };
export type SessionLoadResult =
| { ok: true; storageState: object }
| { ok: false; error: SessionLoadError };
/**
* Load and decrypt the storageState for the given session profile.
*
* Returns `{ ok: true, storageState }` on success.
* Returns `{ ok: false, error }` when the profile is missing, wrong owner,
* not active, or decryption fails.
* Never throws — all errors are surfaced via the returned error shape.
*/
export async function loadSessionStateForUser(
deps: SessionLoaderDeps,
ownerId: string,
sessionProfileId: number,
): Promise<SessionLoadResult> {
const { sessRepo, masterKeyPath } = deps;
// Owner-scoped lookup — ensures profile belongs to ownerId
const profile = sessRepo.getProfileById(sessionProfileId, ownerId);
if (!profile) {
return {
ok: false,
error: {
kind: 'profile_not_found',
message: `session profile ${sessionProfileId} not found or does not belong to this user`,
},
};
}
if (profile.status !== 'active' || !profile.encryptedStateBlob) {
return {
ok: false,
error: {
kind: 'profile_not_active',
message: `session profile ${sessionProfileId} is not active (status=${profile.status})`,
},
};
}
try {
const master = initMasterKey(masterKeyPath);
const encDek = sessRepo.getUserDek(ownerId);
if (!encDek) {
return {
ok: false,
error: {
kind: 'dek_not_found',
message: 'user DEK not found — the session profile has not been saved yet',
},
};
}
const dek = decryptUserDek(master, encDek);
const stateJson = decryptStateBlob(dek, profile.encryptedStateBlob);
const storageState = JSON.parse(stateJson) as object;
return { ok: true, storageState };
} catch (err) {
logger.error(`[session-loader] storageState decrypt failed: ${(err as Error).message}`);
return {
ok: false,
error: {
kind: 'decrypt_error',
message: `failed to decrypt session storageState: ${(err as Error).message}`,
},
};
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 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,
);
}
+183
View File
@@ -0,0 +1,183 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync, existsSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runTrashCleanup, startTrashCleanup } from './trash-cleanup.js';
const DAY_MS = 86_400_000;
function ageFile(path: string, daysOld: number): void {
const t = (Date.now() - daysOld * DAY_MS) / 1000;
utimesSync(path, t, t);
}
function setupUser(root: string, userId: string): string {
const trash = join(root, userId, 'trash');
mkdirSync(trash, { recursive: true });
return trash;
}
describe('user-folder/trash-cleanup', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'trash-cleanup-test-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it('deletes files older than retentionDays and keeps fresh ones', async () => {
const trash = setupUser(root, 'alice');
const stale = join(trash, 'stale.txt');
const fresh = join(trash, 'fresh.txt');
writeFileSync(stale, 'old');
writeFileSync(fresh, 'new');
ageFile(stale, 40);
ageFile(fresh, 5);
const result = await runTrashCleanup(root, 30);
expect(result.scannedUsers).toBe(1);
expect(result.deletedFiles).toBe(1);
expect(existsSync(stale)).toBe(false);
expect(existsSync(fresh)).toBe(true);
});
it('returns 0 deletes when nothing is eligible', async () => {
const trash = setupUser(root, 'alice');
const fresh = join(trash, 'fresh.txt');
writeFileSync(fresh, 'new');
ageFile(fresh, 5);
const result = await runTrashCleanup(root, 30);
expect(result.scannedUsers).toBe(1);
expect(result.deletedFiles).toBe(0);
expect(existsSync(fresh)).toBe(true);
});
it('sweeps every user under the root', async () => {
const aliceTrash = setupUser(root, 'alice');
const bobTrash = setupUser(root, 'bob');
const charlieTrash = setupUser(root, 'charlie');
const aliceStale = join(aliceTrash, 'a.txt');
const bobStale = join(bobTrash, 'b.txt');
const charlieFresh = join(charlieTrash, 'c.txt');
writeFileSync(aliceStale, 'a');
writeFileSync(bobStale, 'b');
writeFileSync(charlieFresh, 'c');
ageFile(aliceStale, 100);
ageFile(bobStale, 100);
ageFile(charlieFresh, 1);
const result = await runTrashCleanup(root, 30);
expect(result.scannedUsers).toBe(3);
expect(result.deletedFiles).toBe(2);
expect(existsSync(aliceStale)).toBe(false);
expect(existsSync(bobStale)).toBe(false);
expect(existsSync(charlieFresh)).toBe(true);
});
it('retentionDays=0 deletes every file regardless of age', async () => {
const trash = setupUser(root, 'alice');
const fresh = join(trash, 'fresh.txt');
writeFileSync(fresh, 'just now');
// no ageFile — mtime is essentially Date.now()
const result = await runTrashCleanup(root, 0);
expect(result.deletedFiles).toBe(1);
expect(existsSync(fresh)).toBe(false);
});
it('recurses into nested directories and removes empties', async () => {
const trash = setupUser(root, 'alice');
const nestedDir = join(trash, 'project/old');
mkdirSync(nestedDir, { recursive: true });
const nested = join(nestedDir, 'leaf.txt');
writeFileSync(nested, 'deep');
ageFile(nested, 100);
const result = await runTrashCleanup(root, 30);
expect(result.deletedFiles).toBe(1);
expect(existsSync(nested)).toBe(false);
expect(existsSync(nestedDir)).toBe(false);
expect(existsSync(join(trash, 'project'))).toBe(false);
});
it('skips users that have no trash/ dir', async () => {
mkdirSync(join(root, 'noTrash', 'scripts'), { recursive: true });
const aliceTrash = setupUser(root, 'alice');
const stale = join(aliceTrash, 'a.txt');
writeFileSync(stale, 'a');
ageFile(stale, 100);
const result = await runTrashCleanup(root, 30);
expect(result.scannedUsers).toBe(1); // only alice
expect(result.deletedFiles).toBe(1);
});
it('returns empty result when root does not exist', async () => {
const missing = join(root, 'does-not-exist');
const result = await runTrashCleanup(missing, 30);
expect(result).toEqual({ scannedUsers: 0, deletedFiles: 0, freedBytes: 0 });
});
it('rejects negative retentionDays', async () => {
await expect(runTrashCleanup(root, -1)).rejects.toThrow(/non-negative/);
});
it('startTrashCleanup runs an immediate sweep and the interval is unref-safe', async () => {
const trash = setupUser(root, 'alice');
const stale = join(trash, 'a.txt');
writeFileSync(stale, 'a');
ageFile(stale, 100);
const handle = startTrashCleanup({
userFolderRoot: root,
retentionDays: 30,
intervalMs: 60_000,
});
try {
// Await the boot sweep directly instead of polling — this was flaky
// when concurrent vitest suites starved the setImmediate queue.
await handle.initialSweep;
expect(existsSync(stale)).toBe(false);
} finally {
handle.stop();
}
});
it('tracks freedBytes from deleted files', async () => {
const trash = setupUser(root, 'alice');
const stale = join(trash, 'big.txt');
writeFileSync(stale, 'x'.repeat(1024));
ageFile(stale, 100);
const result = await runTrashCleanup(root, 30);
expect(result.deletedFiles).toBe(1);
expect(result.freedBytes).toBe(1024);
});
it('keeps fresh files when sibling stale files are removed', async () => {
const trash = setupUser(root, 'alice');
const stale = join(trash, 'old.txt');
const fresh = join(trash, 'new.txt');
writeFileSync(stale, 'x');
writeFileSync(fresh, 'y');
ageFile(stale, 60);
ageFile(fresh, 1);
await runTrashCleanup(root, 30);
expect(existsSync(stale)).toBe(false);
expect(existsSync(fresh)).toBe(true);
expect(statSync(fresh).size).toBe(1);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* trash-cleanup.ts
*
* Periodic GC for `data/users/{ownerId}/trash/`. Files older than
* `retentionDays` (mtime-based) are unlinked. Empty subdirectories left
* behind are removed too.
*
* Trigger: one sweep at boot + setInterval(24h). The interval is unref()'d
* so it does not keep the event loop alive on its own.
*/
import { promises as fs } from 'node:fs';
import type { Dirent } from 'node:fs';
import { join } from 'node:path';
import { logger } from '../logger.js';
const DAY_MS = 86_400_000;
const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
export interface RunTrashCleanupResult {
scannedUsers: number;
deletedFiles: number;
freedBytes: number;
}
/**
* Walk `{rootDir}/{userId}/trash/` for every user and delete files whose
* mtime is older than `retentionDays`. With retentionDays=0 every file is
* eligible (useful for tests / aggressive cleanup).
*/
export async function runTrashCleanup(
rootDir: string,
retentionDays: number,
): Promise<RunTrashCleanupResult> {
const result: RunTrashCleanupResult = {
scannedUsers: 0,
deletedFiles: 0,
freedBytes: 0,
};
if (!Number.isFinite(retentionDays) || retentionDays < 0) {
throw new Error(`runTrashCleanup: retentionDays must be a non-negative finite number, got ${retentionDays}`);
}
// retentionDays=0 → delete everything regardless of mtime. We can't just compute
// `Date.now() - 0` because file mtime can have sub-ms precision slightly newer than
// the integer-truncated Date.now(), which would skip a just-written file.
const cutoff = retentionDays === 0 ? Number.POSITIVE_INFINITY : Date.now() - retentionDays * DAY_MS;
let userEntries: Dirent[];
try {
userEntries = await fs.readdir(rootDir, { withFileTypes: true });
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') return result; // user folder root does not exist yet — nothing to do
throw err;
}
for (const userEntry of userEntries) {
if (!userEntry.isDirectory()) continue;
const trashDir = join(rootDir, userEntry.name, 'trash');
let stat;
try {
stat = await fs.stat(trashDir);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') continue;
logger.warn(`[trash-cleanup] stat failed user=${userEntry.name} err=${e.message}`);
continue;
}
if (!stat.isDirectory()) continue;
result.scannedUsers++;
await sweepDir(trashDir, cutoff, result, userEntry.name);
}
return result;
}
async function sweepDir(
dir: string,
cutoff: number,
result: RunTrashCleanupResult,
userId: string,
): Promise<void> {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
const e = err as NodeJS.ErrnoException;
logger.warn(`[trash-cleanup] readdir failed user=${userId} dir=${dir} err=${e.message}`);
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await sweepDir(full, cutoff, result, userId);
// remove the directory if it became empty after the sweep
try {
const remaining = await fs.readdir(full);
if (remaining.length === 0) await fs.rmdir(full);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ENOENT') {
logger.warn(`[trash-cleanup] rmdir failed user=${userId} dir=${full} err=${e.message}`);
}
}
continue;
}
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
try {
const fileStat = await fs.lstat(full);
if (fileStat.mtimeMs > cutoff) continue;
const size = fileStat.size;
await fs.unlink(full);
result.deletedFiles++;
result.freedBytes += size;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') continue; // already gone
logger.warn(`[trash-cleanup] unlink failed user=${userId} path=${full} err=${e.message}`);
}
}
}
export interface StartTrashCleanupOptions {
userFolderRoot: string;
retentionDays: number;
intervalMs?: number; // override for tests
}
/**
* Run one sweep at boot, then schedule a daily sweep. Returns a stop()
* function and an `initialSweep` promise that tests can await to guarantee
* the boot sweep has completed. The interval is unref()'d so it does not
* block process exit.
*/
export function startTrashCleanup(opts: StartTrashCleanupOptions): {
stop: () => void;
initialSweep: Promise<void>;
} {
const intervalMs = opts.intervalMs ?? SWEEP_INTERVAL_MS;
logger.info(`[trash-cleanup] starting root=${opts.userFolderRoot} retentionDays=${opts.retentionDays} intervalMs=${intervalMs}`);
const sweep = (): Promise<void> =>
runTrashCleanup(opts.userFolderRoot, opts.retentionDays)
.then((res) => {
if (res.deletedFiles > 0) {
logger.info(`[trash-cleanup] swept users=${res.scannedUsers} deleted=${res.deletedFiles} freedBytes=${res.freedBytes}`);
} else {
logger.info(`[trash-cleanup] swept users=${res.scannedUsers} deleted=0`);
}
})
.catch((err: Error) => {
logger.warn(`[trash-cleanup] sweep failed err=${err.message}`);
});
const initialSweep = sweep();
const handle = setInterval(() => { void sweep(); }, intervalMs);
handle.unref();
return {
stop: () => clearInterval(handle),
initialSweep,
};
}