diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index d5fd2311..bf148d92 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -48,6 +48,7 @@ const behaviorTests = [ ".test-dist/tests/agent-setup-support.test.js", ".test-dist/tests/plugin-config.test.js", ".test-dist/tests/plugin-sdk-bridge.test.js", + ".test-dist/tests/plugin-sdk-runtime-helpers.test.js", ".test-dist/tests/plugin-sdk-surface-boundaries.test.js", ".test-dist/tests/plugin-state.test.js", ".test-dist/tests/plugin-runtime.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 36f2cee0..200dd9ed 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -118,6 +118,8 @@ const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "ut const agentSetupCommandContextSource = readFileSync(join(appDir, "src", "agent-setup-command-context.ts"), "utf8"); const agentSetupEditorToolsSource = readFileSync(join(appDir, "src", "agent-setup-editor-tools.ts"), "utf8"); const agentSetupSupportSource = readFileSync(join(appDir, "src", "agent-setup-support.ts"), "utf8"); +const pluginSdkBridgeSource = readFileSync(join(appDir, "src", "plugin-sdk-bridge.ts"), "utf8"); +const pluginSdkRuntimeHelpersSource = readFileSync(join(appDir, "src", "plugin-sdk-runtime-helpers.ts"), "utf8"); const pluginServiceSource = readFileSync(join(appDir, "src", "plugin-service.ts"), "utf8"); const pluginServiceDefaultPetSource = readFileSync(join(appDir, "src", "plugin-service-default-pet.ts"), "utf8"); const pluginServiceSnapshotSource = readFileSync(join(appDir, "src", "plugin-service-snapshot.ts"), "utf8"); @@ -274,6 +276,8 @@ assert.match(controlCenterSettingsCombinedSource, /API key or token|OpenAI API k assert.match(pluginServiceSource, /from "\.\/plugin-service-default-pet(?:\.js)?"/, "plugin-service must import the extracted default-pet helper seam."); assert.match(pluginServiceSource, /from "\.\/plugin-service-snapshot(?:\.js)?"/, "plugin-service must import the extracted snapshot helper seam."); assert.match(pluginServiceSource, /from "\.\/plugin-service-text-helpers(?:\.js)?"/, "plugin-service must import the extracted plugin text helper seam."); +assert.match(pluginSdkBridgeSource, /from "\.\/plugin-sdk-runtime-helpers(?:\.js)?"/, "plugin-sdk-bridge must import the extracted runtime helper seam."); +assert.match(pluginSdkRuntimeHelpersSource, /export function nextScheduleDelayMs/, "plugin-sdk runtime helper seam must own schedule delay calculation."); assert.match(pluginServiceDefaultPetSource, /export async function buildDefaultPetPluginCommands/, "plugin-service default-pet helper must export default familiar command shaping."); assert.match(pluginServiceSnapshotSource, /export async function buildSafePluginRecord/, "plugin-service snapshot helper must export safe plugin snapshot shaping."); assert.match(pluginServiceSnapshotSource, /export function validatePluginServiceConfigReplacement/, "plugin-service snapshot helper must export config validation delegation."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 7e3e2576..6b1df38c 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -120,7 +120,7 @@ main.ts → initializePluginService(userData, defaultPluginPetApi, appVersion, E ├── plugin-runtime.ts reloads enabled manifests │ ├── declarative runtime schedules timer triggers │ ├── plugin-js-host.ts starts hidden sandboxed BrowserWindow hosts for JavaScript plugins -│ └── plugin-sdk-bridge.ts → plugin-familiar-api.ts/default-familiar-controller plus schedules, storage, commands, status, logs, and restricted network +│ └── plugin-sdk-bridge.ts → plugin-sdk-runtime-helpers.ts plus plugin-familiar-api.ts/default-familiar-controller for schedules, storage, commands, status, logs, and restricted network ├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, install/update/uninstall/load-local, and runtime reloads └── lifecycle.ts → stopPluginService() on quit @@ -231,7 +231,8 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only fam - `plugin-package.ts`: Catalog plugin package download/install with HTTPS host/path allowlist, SHA-256 verification, ZIP size/entry restrictions, manifest/catalog consistency checks, and safe uninstall path resolution. - `plugin-local-loader.ts`: Developer loader that validates a selected local folder and snapshots only the manifest into `plugins-dev` with symlink/path/size protections. - `plugin-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, SDK IPC tokening, registration handshake, config listener cleanup, and teardown. -- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for familiar speech/reactions, one-shot/repeating/daily schedules, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch. +- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for familiar speech/reactions, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch. +- `plugin-sdk-runtime-helpers.ts`: Extracted schedule timing, timeout wrapping, bubble counting, and callback error normalization helpers for the JavaScript plugin SDK bridge. **Agent Integration**: - `agent-setup.ts`: Claude detection, MCP configuration orchestration, hooks management, action journaling diff --git a/apps/desktop/src/plugin-sdk-bridge.ts b/apps/desktop/src/plugin-sdk-bridge.ts index 26c08219..911579d8 100644 --- a/apps/desktop/src/plugin-sdk-bridge.ts +++ b/apps/desktop/src/plugin-sdk-bridge.ts @@ -24,6 +24,13 @@ import { validateNetOptions, } from "./plugin-sdk-network.js"; import { pluginSdkQuotas } from "./plugin-sdk-quotas.js"; +import { + countActiveBubbles, + nextScheduleDelayMs, + parseDaily, + safeError, + withTimeout, +} from "./plugin-sdk-runtime-helpers.js"; import { createPluginAudioApi, createPluginBusApi, @@ -40,7 +47,6 @@ import { commandIdPattern, isRecord, namedHostIcons, - nextCronRunMs, normalizeJson, parseCronExpression, renderLimitedMarkdown, @@ -681,19 +687,3 @@ export class PluginSdkBridge { return state; } } - -function countActiveBubbles(state: PluginRuntimeState): number { return state.bubbles.size; } -function parseDaily(spec: string | { time: string; days?: number[] }): { time: string; days?: number[] } { const value = typeof spec === "string" ? { time: spec } : spec; const m = /^(\d{2}):(\d{2})$/.exec(value.time); if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error("Daily schedule time must be HH:mm between 00:00 and 23:59."); if (value.days && (!Array.isArray(value.days) || value.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) throw new Error("Daily schedule days must be weekdays 0-6."); return value; } -function msUntilDaily(spec: { time: string; days?: number[] }): number { const [hour, minute] = spec.time.split(":").map(Number); const now = new Date(); for (let add = 0; add <= 7; add += 1) { const next = new Date(now); next.setDate(now.getDate() + add); next.setHours(hour ?? 0, minute ?? 0, 0, 0); if (next > now && (!spec.days || spec.days.includes(next.getDay()))) return next.getTime() - now.getTime(); } return 24 * 60 * 60 * 1000; } - -function nextScheduleDelayMs(spec: ScheduleSpec): number | null { - if (spec.type === "once") return Math.max(1, spec.delayMs); - if (spec.type === "every") return Math.max(1, spec.intervalMs); - if (spec.type === "daily") return msUntilDaily(spec.daily); - if (spec.type === "at") return Math.max(1_000, spec.timestamp - Date.now()); - const next = nextCronRunMs(spec.expr, Date.now()); - return next === null ? null : Math.max(1_000, next - Date.now()); -} - -function withTimeout(promise: Promise, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("Plugin command timed out.")), timeoutMs); promise.then((v) => { clearTimeout(timeout); resolve(v); }, (e) => { clearTimeout(timeout); reject(e); }); }); } -function safeError(error: unknown): string { return error instanceof Error ? error.message : "Plugin SDK callback failed."; } diff --git a/apps/desktop/src/plugin-sdk-runtime-helpers.ts b/apps/desktop/src/plugin-sdk-runtime-helpers.ts new file mode 100644 index 00000000..84fd6fdb --- /dev/null +++ b/apps/desktop/src/plugin-sdk-runtime-helpers.ts @@ -0,0 +1,61 @@ +import type { PluginRuntimeState, ScheduleSpec } from "./plugin-sdk-state.js"; +import { nextCronRunMs } from "./plugin-sdk-validators.js"; + +export function countActiveBubbles(state: PluginRuntimeState): number { + return state.bubbles.size; +} + +export function parseDaily(spec: string | { time: string; days?: number[] }): { time: string; days?: number[] } { + const value = typeof spec === "string" ? { time: spec } : spec; + const match = /^(\d{2}):(\d{2})$/.exec(value.time); + if (!match || Number(match[1]) > 23 || Number(match[2]) > 59) { + throw new Error("Daily schedule time must be HH:mm between 00:00 and 23:59."); + } + if (value.days && (!Array.isArray(value.days) || value.days.some((day) => !Number.isInteger(day) || day < 0 || day > 6))) { + throw new Error("Daily schedule days must be weekdays 0-6."); + } + return value; +} + +export function nextScheduleDelayMs(spec: ScheduleSpec): number | null { + if (spec.type === "once") return Math.max(1, spec.delayMs); + if (spec.type === "every") return Math.max(1, spec.intervalMs); + if (spec.type === "daily") return msUntilDaily(spec.daily); + if (spec.type === "at") return Math.max(1_000, spec.timestamp - Date.now()); + const next = nextCronRunMs(spec.expr, Date.now()); + return next === null ? null : Math.max(1_000, next - Date.now()); +} + +export function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Plugin command timed out.")), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +export function safeError(error: unknown): string { + return error instanceof Error ? error.message : "Plugin SDK callback failed."; +} + +function msUntilDaily(spec: { time: string; days?: number[] }): number { + const [hour, minute] = spec.time.split(":").map(Number); + const now = new Date(); + for (let add = 0; add <= 7; add += 1) { + const next = new Date(now); + next.setDate(now.getDate() + add); + next.setHours(hour ?? 0, minute ?? 0, 0, 0); + if (next > now && (!spec.days || spec.days.includes(next.getDay()))) { + return next.getTime() - now.getTime(); + } + } + return 24 * 60 * 60 * 1000; +} diff --git a/apps/desktop/tests/plugin-sdk-runtime-helpers.test.ts b/apps/desktop/tests/plugin-sdk-runtime-helpers.test.ts new file mode 100644 index 00000000..a8dacd17 --- /dev/null +++ b/apps/desktop/tests/plugin-sdk-runtime-helpers.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { countActiveBubbles, nextScheduleDelayMs, parseDaily, safeError, withTimeout } from "../src/plugin-sdk-runtime-helpers.js"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pluginSdkBridgeSource = readFileSync(resolve(desktopRoot, "src/plugin-sdk-bridge.ts"), "utf8"); +const pluginSdkRuntimeHelpersSource = readFileSync(resolve(desktopRoot, "src/plugin-sdk-runtime-helpers.ts"), "utf8"); + +assert.match(pluginSdkBridgeSource, /from "\.\/plugin-sdk-runtime-helpers\.js"/, "Plugin SDK bridge must import the extracted runtime helper seam."); +assert.match(pluginSdkRuntimeHelpersSource, /export function nextScheduleDelayMs/, "Plugin SDK runtime helpers must export schedule delay calculation."); +assert.match(pluginSdkRuntimeHelpersSource, /export function withTimeout/, "Plugin SDK runtime helpers must export timeout wrapping."); + +assert.deepEqual(parseDaily("09:15"), { time: "09:15" }); +assert.deepEqual(parseDaily({ time: "18:45", days: [1, 3, 5] }), { time: "18:45", days: [1, 3, 5] }); +assert.throws(() => parseDaily("25:00"), /Daily schedule time must be HH:mm/); +assert.throws(() => parseDaily({ time: "12:30", days: [7] }), /Daily schedule days must be weekdays 0-6/); + +assert.equal(nextScheduleDelayMs({ type: "once", delayMs: 0 }), 1); +assert.equal(nextScheduleDelayMs({ type: "every", intervalMs: 25 }), 25); +assert.equal(nextScheduleDelayMs({ type: "at", timestamp: Date.now() - 5_000 }), 1_000); + +assert.equal(safeError(new Error("boom")), "boom"); +assert.equal(safeError("boom"), "Plugin SDK callback failed."); +assert.equal(countActiveBubbles({ bubbles: new Map([["one", {}], ["two", {}]]) } as never), 2); + +await assert.rejects(() => withTimeout(new Promise(() => undefined), 5), /Plugin command timed out\./); +await assert.doesNotReject(() => withTimeout(Promise.resolve("ok"), 50)); + +console.error("Plugin SDK runtime helper validation passed.");