import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { FAMILIAROS_PLUGIN_MANIFEST_FILENAME, type FamiliarOSDeclarativePluginManifest, type FamiliarOSJavascriptPluginManifest } from "../src/plugin-manifest.js"; import { type PluginPetApi } from "../src/plugin-familiar-api.js"; import { PluginRuntime, type PluginRuntimeScheduler, type PluginTimerHandle } from "../src/plugin-runtime.js"; import type { PluginJsHost, PluginJsHostInstance, PluginJsHostStartOptions } from "../src/plugin-js-host.js"; import { initializePluginState, type PluginStateStore, type PluginStateRecord } from "../src/plugin-state.js"; let currentRoot = ""; class FakeScheduler implements PluginRuntimeScheduler { timers: Array<{ delayMs: number; active: boolean; callback: () => void }> = []; setTimeout(callback: () => void, delayMs: number): PluginTimerHandle { const timer = { delayMs, active: true, callback }; this.timers.push(timer); return { cancel: () => { timer.active = false; } }; } fire(index = 0): void { const timer = this.timers[index]; if (timer?.active) { timer.active = false; timer.callback(); } } activeCount(): number { return this.timers.filter((timer) => timer.active).length; } } class FakePetApi implements PluginPetApi { events: string[] = []; fail = false; deferredFailure: { reject: (error: Error) => void } | undefined; speak(message: string): void { if (this.fail) throw new Error("familiar api failed"); this.events.push(`speak:${message}`); } react(reaction: string): void | Promise { if (this.deferredFailure) return new Promise((_resolve, reject) => { this.deferredFailure = { reject }; }); if (this.fail) throw new Error("familiar api failed"); this.events.push(`react:${reaction}`); } moveBy(options: { x: number; y: number; durationMs?: number }): void { this.events.push(`moveBy:${options.x},${options.y},${options.durationMs ?? ""}`); } wander(options: { distance?: number; durationMs?: number }): void { this.events.push(`wander:${options.distance ?? ""},${options.durationMs ?? ""}`); } moveToHome(): void { this.events.push("moveToHome"); } } class FakeJsHost implements PluginJsHost { starts: PluginJsHostStartOptions[] = []; instances: Array<{ stopped: boolean; id: string }> = []; fail = false; breakBeforeReturn = false; async startPlugin(options: PluginJsHostStartOptions): Promise { this.starts.push(options); if (this.fail) throw new Error("host failed"); const instance = { stopped: false, id: options.record.id }; this.instances.push(instance); if (options.record.approvedPermissions.includes("commands")) options.sdk?.commands.register({ id: "run", title: "Run" }, () => options.sdk?.status.set("ran")); if (this.breakBeforeReturn) options.onBroken("early crash"); return { stop: () => { instance.stopped = true; } }; } } await scenario("disabled no timers", async ({ store, scheduler }) => { addPlugin(store, { enabled: false }); await runtime(store, scheduler).start(); assert.equal(scheduler.activeCount(), 0); }); await scenario("javascript sdk command status storage schedule and permissions", async ({ store, scheduler, petApi }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["commands", "status", "storage", "schedule", "familiar:speak"] }, jsManifest({ permissions: ["commands", "status", "storage", "schedule", "familiar:speak"] })); const rt = runtime(store, scheduler, petApi, undefined, jsHost); await rt.start(); assert.deepEqual(rt.getPluginState("plug").commands.map((c) => c.id), ["run"]); await rt.executeCommand("plug", "run"); assert.equal(rt.getPluginState("plug").status?.text, "ran"); jsHost.starts[0].sdk?.storage.set("a", "b"); assert.equal(jsHost.starts[0].sdk?.storage.get("a"), "b"); assert.equal(store.getRecord("plug")?.config["storage:a"], undefined); jsHost.starts[0].sdk?.schedule.once("s", 1, () => jsHost.starts[0].sdk?.familiar.speak("hi")); scheduler.fire(0); await Promise.resolve(); assert.deepEqual(petApi.events, ["speak:hi"]); }); await scenario("javascript sdk permission rejection", async ({ store }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["commands"] }, jsManifest({ permissions: ["commands"] })); await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); assert.throws(() => jsHost.starts[0].sdk?.storage.set("a", "b"), /not approved/); }); await scenario("javascript sdk familiar movement requires familiar move permission", async ({ store, scheduler, petApi }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["familiar:move"] }, jsManifest({ permissions: ["familiar:move"] })); await runtime(store, scheduler, petApi, undefined, jsHost).start(); await jsHost.starts[0].sdk?.familiar.moveBy({ x: 20, y: -10, durationMs: 500 }); await jsHost.starts[0].sdk?.familiar.wander({ distance: 40, durationMs: 700 }); await jsHost.starts[0].sdk?.familiar.moveToHome(); assert.deepEqual(petApi.events, ["moveBy:20,-10,500", "wander:40,700", "moveToHome"]); }); await scenario("javascript http fetch allows approved github host", async ({ store }) => { const originalFetch = globalThis.fetch; const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["network"], approvedNetworkHosts: ["api.github.com"] }, jsManifest({ permissions: ["network"], network: { hosts: ["api.github.com"] } })); globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json", etag: "abc" } })) as typeof fetch; try { await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); const result = await jsHost.starts[0].sdk?.http.fetch("https://api.github.com/repos/familiaros/familiaros/releases"); assert.equal(result?.status, 200); assert.deepEqual(result?.json, { ok: true }); assert.equal(result?.headers.etag, "abc"); } finally { globalThis.fetch = originalFetch; } }); await scenario("javascript http fetch denies unapproved host and non-get", async ({ store }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["network"], approvedNetworkHosts: ["api.github.com"] }, jsManifest({ permissions: ["network"], network: { hosts: ["api.github.com"] } })); await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); await assert.rejects(() => jsHost.starts[0].sdk!.http.fetch("https://example.com/"), /host is not approved/); await assert.rejects(() => jsHost.starts[0].sdk!.http.fetch("https://api.github.com/", { method: "POST" }), /only supports GET/); }); await scenario("javascript http fetch rejects oversized response", async ({ store }) => { const originalFetch = globalThis.fetch; const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["network"], approvedNetworkHosts: ["api.github.com"] }, jsManifest({ permissions: ["network"], network: { hosts: ["api.github.com"] } })); globalThis.fetch = (async () => new Response("x".repeat(4 * 1024 * 1024 + 1), { status: 200 })) as typeof fetch; try { await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); await assert.rejects(() => jsHost.starts[0].sdk!.http.fetch("https://api.github.com/"), /too large/); } finally { globalThis.fetch = originalFetch; } }); await scenario("javascript plugin starts through host", async ({ store }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest()); await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); assert.equal(jsHost.starts.length, 1); assert.equal(store.getRecord("plug")?.brokenReason, undefined); }); await scenario("javascript stop cancels host", async ({ store }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest()); const rt = runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost); await rt.start(); rt.stop(); assert.equal(jsHost.instances[0].stopped, true); }); await scenario("javascript startup failure marks broken", async ({ store }) => { const jsHost = new FakeJsHost(); jsHost.fail = true; addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest()); await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /host failed/); }); await scenario("javascript early crash marks broken", async ({ store }) => { const jsHost = new FakeJsHost(); jsHost.breakBeforeReturn = true; addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest()); await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /early crash/); }); await scenario("disabled and killed javascript plugins do not start", async ({ store }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", enabled: false, approvedPermissions: [] }, jsManifest()); addPlugin(store, { id: "killed", manifestVersion: 2, runtime: "javascript", catalogDisabled: true, approvedPermissions: [] }, jsManifest({ id: "killed" })); await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start(); assert.equal(jsHost.starts.length, 0); }); await scenario("javascript reload replaces host", async ({ store }) => { const jsHost = new FakeJsHost(); addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest()); const rt = runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost); await rt.start(); await rt.reloadPlugin("plug"); assert.equal(jsHost.instances[0].stopped, true); assert.equal(jsHost.instances[1].stopped, false); }); await scenario("valid timer schedules and fires", async ({ store, scheduler, petApi }) => { addPlugin(store, {}, manifest({ permissions: ["timer", "familiar:speak", "familiar:reaction"], actions: [{ type: "familiar.speak", message: "Stretch" }, { type: "familiar.react", reaction: "celebrating" }] })); await runtime(store, scheduler, petApi).start(); assert.equal(scheduler.timers[0].delayMs, 5 * 60_000); assert.deepEqual(petApi.events, []); scheduler.fire(0); await Promise.resolve(); assert.deepEqual(petApi.events, ["speak:Stretch", "react:celebrating"]); }); await scenario("config speak and reaction refs execute", async ({ store, scheduler, petApi }) => { addPlugin(store, { config: { message: "Hello", reaction: "celebrating" } }, manifest({ permissions: ["timer", "familiar:speak", "familiar:reaction"], configSchema: { message: { type: "text", default: "Stretch" }, reaction: { type: "select", default: "idle", options: [{ label: "Idle", value: "idle" }, { label: "Celebrate", value: "celebrating" }] } }, actions: [{ type: "familiar.speak", message: { config: "message" } }, { type: "familiar.react", reaction: { config: "reaction" } }] })); await runtime(store, scheduler, petApi).start(); scheduler.fire(0); await Promise.resolve(); assert.deepEqual(petApi.events, ["speak:Hello", "react:celebrating"]); }); await scenario("invalid persisted config refs mark broken without api call", async ({ store, scheduler, petApi }) => { addPlugin(store, { config: { message: 42 } }, manifest({ configSchema: { message: { type: "text", default: "Stretch" } }, actions: [{ type: "familiar.speak", message: { config: "message" } }] })); await runtime(store, scheduler, petApi).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /invalid/); assert.equal(scheduler.activeCount(), 0); assert.deepEqual(petApi.events, []); }); await scenario("final config message and reaction validation applies", async ({ store }) => { addPlugin(store, { config: { message: "https://example.test" } }, manifest({ configSchema: { message: { type: "text", default: "Stretch" } }, actions: [{ type: "familiar.speak", message: { config: "message" } }] })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /URL/); }); await scenario("unapproved permission broken", async ({ store, scheduler }) => { addPlugin(store, { approvedPermissions: ["timer"] }, manifest({ permissions: ["timer", "familiar:speak"] })); await runtime(store, scheduler).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /not approved/); assert.equal(scheduler.activeCount(), 0); assert.equal(store.getRecord("plug")?.enabled, true); }); await scenario("invalid manifest and id-version mismatch broken", async ({ root, store }) => { const first = addPlugin(store, {}, { bad: true }); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /validation failed/); addPlugin(store, { id: "other" }, manifest({ id: "plug" })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("other")?.brokenReason ?? "", /id\/version/); }); await scenario("invalid reaction and message broken", async ({ store }) => { addPlugin(store, {}, manifest({ permissions: ["timer", "familiar:reaction"], actions: [{ type: "familiar.react", reaction: "celebrate" }] })); addPlugin(store, { id: "bad-message" }, manifest({ id: "bad-message", actions: [{ type: "familiar.speak", message: "https://example.test" }] })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /Invalid familiar reaction/); assert.match(store.getRecord("bad-message")?.brokenReason ?? "", /URL/); }); await scenario("config interval below five broken", async ({ store }) => { addPlugin(store, { config: { intervalMinutes: 4 } }, manifest({ everyMinutes: { config: "intervalMinutes" }, configSchema: { intervalMinutes: { type: "number", default: 6 } } })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /at least 5/); }); await scenario("stop cancels and stale timers do not fire", async ({ store, scheduler, petApi }) => { addPlugin(store); const rt = runtime(store, scheduler, petApi); await rt.start(); const stale = scheduler.timers[0]; rt.stop(); assert.equal(stale.active, false); stale.callback(); assert.deepEqual(petApi.events, []); }); await scenario("reload cancels stale timer", async ({ store, scheduler, petApi }) => { addPlugin(store); const rt = runtime(store, scheduler, petApi); await rt.start(); const stale = scheduler.timers[0]; await rt.reloadPlugin("plug"); stale.callback(); assert.deepEqual(petApi.events, []); scheduler.fire(1); await Promise.resolve(); assert.deepEqual(petApi.events, ["speak:Stretch"]); }); await scenario("path outside install/root and oversized rejected", async ({ root, store }) => { const outsideRoot = tempDir(); addPlugin(store, { installPath: outsideRoot, manifestPath: writeManifest(outsideRoot, manifest()) }); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /outside allowed/); const install = join(root, "oversized"); mkdirSync(install, { recursive: true }); const path = join(install, FAMILIAROS_PLUGIN_MANIFEST_FILENAME); writeFileSync(path, JSON.stringify(manifest()) + " ".repeat(200), "utf8"); store.upsertRecord(record({ id: "oversized", installPath: install, manifestPath: path })); await new PluginRuntime({ stateStore: store, petApi: new FakePetApi(), scheduler: new FakeScheduler(), allowedPluginRoots: [root], maxManifestBytes: 100 }).start(); assert.match(store.getRecord("oversized")?.brokenReason ?? "", /too large/); }); await scenario("manifest outside install rejected", async ({ root, store }) => { const goodInstall = join(root, "good"); const otherInstall = join(root, "other"); mkdirSync(goodInstall, { recursive: true }); const manifestPath = writeManifest(otherInstall, manifest()); store.upsertRecord(record({ installPath: goodInstall, manifestPath })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /outside install/); }); await scenario("root-level evil manifest filename rejected", async ({ root, store }) => { const install = join(root, "evil-name"); mkdirSync(install, { recursive: true }); const manifestPath = join(install, `evil-${FAMILIAROS_PLUGIN_MANIFEST_FILENAME}`); writeFileSync(manifestPath, JSON.stringify(manifest()), "utf8"); store.upsertRecord(record({ installPath: install, manifestPath })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /manifest path is invalid/); }); await scenario("reloadAll cancels removed plugin timers", async ({ store, scheduler, petApi }) => { addPlugin(store); const rt = runtime(store, scheduler, petApi); await rt.start(); const stale = scheduler.timers[0]; store.removeRecord("plug"); await rt.reloadAll(); assert.equal(stale.active, false); stale.callback(); assert.deepEqual(petApi.events, []); assert.equal(scheduler.activeCount(), 0); }); await scenario("invalid configured timer does not fall back to default", async ({ store }) => { addPlugin(store, { config: { intervalMinutes: "10" } }, manifest({ everyMinutes: { config: "intervalMinutes" }, configSchema: { intervalMinutes: { type: "number", default: 6 } } })); await runtime(store, new FakeScheduler()).start(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /invalid|must resolve to an integer/); }); await scenario("timer handles do not accumulate after repeated fires", async ({ store, scheduler, petApi }) => { addPlugin(store); await runtime(store, scheduler, petApi).start(); assert.equal(scheduler.activeCount(), 1); scheduler.fire(0); await Promise.resolve(); assert.equal(scheduler.activeCount(), 1); scheduler.fire(1); await Promise.resolve(); assert.equal(scheduler.activeCount(), 1); }); await scenario("brokenReason clears and bad plugin not blocking good", async ({ store, scheduler }) => { addPlugin(store, { brokenReason: "old" }); addPlugin(store, { id: "bad" }, manifest({ id: "bad", permissions: ["timer", "familiar:reaction"], actions: [{ type: "familiar.react", reaction: "celebrate" }] })); await runtime(store, scheduler).start(); assert.equal(store.getRecord("plug")?.brokenReason, undefined); assert.match(store.getRecord("bad")?.brokenReason ?? "", /Invalid familiar reaction/); assert.equal(scheduler.activeCount(), 1); }); await scenario("familiar api failure marks broken", async ({ store, scheduler, petApi }) => { addPlugin(store); petApi.fail = true; await runtime(store, scheduler, petApi).start(); scheduler.fire(0); await Promise.resolve(); assert.match(store.getRecord("plug")?.brokenReason ?? "", /familiar api failed/); assert.equal(store.getRecord("plug")?.enabled, true); }); await scenario("stale reload cannot resurrect disabled plugin", async ({ store, scheduler }) => { addPlugin(store); const rt = runtime(store, scheduler); await rt.start(); scheduler.timers = []; const reload = rt.reloadPlugin("plug"); const current = store.getRecord("plug"); assert.ok(current); store.upsertRecord({ ...current, enabled: false }); await rt.reloadPlugin("plug"); await reload; assert.equal(scheduler.activeCount(), 0); }); await scenario("stale reload cannot resurrect stopped runtime", async ({ store, scheduler }) => { addPlugin(store); const rt = runtime(store, scheduler); await rt.start(); scheduler.timers = []; const reload = rt.reloadPlugin("plug"); rt.stop(); await reload; assert.equal(scheduler.activeCount(), 0); }); await scenario("stale async action failure does not break new generation", async ({ store, scheduler, petApi }) => { addPlugin(store, {}, manifest({ permissions: ["timer", "familiar:reaction"], actions: [{ type: "familiar.react", reaction: "celebrating" }] })); petApi.deferredFailure = { reject() {} }; const rt = runtime(store, scheduler, petApi); await rt.start(); scheduler.fire(0); const pending = petApi.deferredFailure; assert.ok(pending); await rt.reloadPlugin("plug"); pending.reject(new Error("stale failure")); await Promise.resolve(); assert.equal(store.getRecord("plug")?.brokenReason, undefined); assert.equal(scheduler.activeCount(), 1); }); console.error("Plugin runtime validation passed."); async function scenario(name: string, fn: (ctx: { root: string; store: PluginStateStore; scheduler: FakeScheduler; petApi: FakePetApi }) => Promise): Promise { const root = tempDir(); currentRoot = root; const store = initializePluginState({ statePath: join(tempDir(), "state.json") }); const scheduler = new FakeScheduler(); const petApi = new FakePetApi(); try { await fn({ root, store, scheduler, petApi }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); } } function runtime(store: PluginStateStore, scheduler: FakeScheduler, petApi = new FakePetApi(), roots?: string[], jsHost?: PluginJsHost): PluginRuntime { return new PluginRuntime({ stateStore: store, petApi, scheduler, allowedPluginRoots: roots ?? [currentRoot], jsHost }); } function addPlugin(store: PluginStateStore, patch: Partial = {}, data: unknown = manifest()): PluginStateRecord { const root = patch.installPath ? join(patch.installPath, "..") : currentRoot; const id = patch.id ?? "plug"; const installPath = patch.installPath ?? join(root, id); const manifestPath = patch.manifestPath ?? writeManifest(installPath, data); if (isJsManifest(data)) writeFileSync(join(installPath, data.entry), "FamiliarOSPlugin.register();", "utf8"); const rec = record({ ...patch, id, installPath, manifestPath }); store.upsertRecord(rec); return rec; } function record(patch: Partial = {}): PluginStateRecord { return { id: patch.id ?? "plug", version: patch.version ?? "1.0.0", manifestPath: patch.manifestPath ?? "", installPath: patch.installPath ?? "", source: patch.source ?? "local", manifestVersion: patch.manifestVersion, runtime: patch.runtime, sdkVersion: patch.sdkVersion, enabled: patch.enabled ?? true, approvedPermissions: patch.approvedPermissions ?? ["timer", "familiar:speak", "familiar:reaction"], approvedNetworkHosts: patch.approvedNetworkHosts, config: patch.config ?? {}, brokenReason: patch.brokenReason, catalogDisabled: patch.catalogDisabled }; } function manifest(patch: Partial & { everyMinutes?: FamiliarOSDeclarativePluginManifest["triggers"][number]["everyMinutes"]; actions?: FamiliarOSDeclarativePluginManifest["triggers"][number]["actions"] } = {}): FamiliarOSDeclarativePluginManifest { return { manifestVersion: 1, id: patch.id ?? "plug", name: "Plug", version: patch.version ?? "1.0.0", runtime: "declarative", permissions: patch.permissions ?? ["timer", "familiar:speak"], configSchema: patch.configSchema, triggers: [{ on: "timer", everyMinutes: patch.everyMinutes ?? 5, actions: patch.actions ?? [{ type: "familiar.speak", message: "Stretch" }] }] }; } function jsManifest(patch: Partial = {}): FamiliarOSJavascriptPluginManifest { return { manifestVersion: 2, id: patch.id ?? "plug", name: "Plug", version: patch.version ?? "1.0.0", runtime: "javascript", sdkVersion: patch.sdkVersion ?? "1.0.0", entry: patch.entry ?? "index.js", permissions: patch.permissions ?? [], network: patch.network }; } function isJsManifest(data: unknown): data is FamiliarOSJavascriptPluginManifest { return typeof data === "object" && data !== null && "runtime" in data && data.runtime === "javascript" && "entry" in data && typeof data.entry === "string"; } function writeManifest(dir: string, data: unknown): string { mkdirSync(dir, { recursive: true }); const path = join(dir, FAMILIAROS_PLUGIN_MANIFEST_FILENAME); writeFileSync(path, JSON.stringify(data), "utf8"); return path; } function tempDir(): string { return mkdtempSync(join(tmpdir(), "familiaros-plugin-runtime-")); }