feat: initial public release (MAESTRO)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: 1–64 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
|
||||
* (1–64 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]`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]`;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user