From c564c13d8a56d3a7fa97c52e90bc1683e048b77b Mon Sep 17 00:00:00 2001 From: Moritz Jung Date: Wed, 27 May 2026 22:01:39 +0200 Subject: [PATCH] update ui --- .../src/execution/worker-execution-session.ts | 102 +++--- .../obsidian/src/rpc/rpc-registry-types.ts | 89 +++++ packages/obsidian/src/rpc/rpc-registry.ts | 112 ++---- packages/obsidian/src/rpc/safe-js-rpc.ts | 9 +- .../obsidian/src/scripts/script-settings.ts | 2 +- packages/obsidian/src/settings.ts | 339 +----------------- .../obsidian/src/settings/settings-schema.ts | 32 ++ .../obsidian/src/ui/add-vault-script-modal.ts | 118 ++++++ packages/obsidian/src/ui/settings-tab.ts | 53 +++ .../ui/settings/runtime-settings-section.ts | 78 ++++ .../settings/stored-data-settings-section.ts | 149 ++++++++ .../vault-scripts-settings-section.ts | 154 ++++++++ .../obsidian/src/worker/safe-js-worker.ts | 12 +- test/execution-service.test.ts | 28 +- test/helper-rpc.test.ts | 5 +- test/public-api.test.ts | 8 +- test/rpc-registry.test.ts | 47 ++- test/sandboxing.test.ts | 9 +- 18 files changed, 824 insertions(+), 522 deletions(-) create mode 100644 packages/obsidian/src/rpc/rpc-registry-types.ts create mode 100644 packages/obsidian/src/settings/settings-schema.ts create mode 100644 packages/obsidian/src/ui/add-vault-script-modal.ts create mode 100644 packages/obsidian/src/ui/settings-tab.ts create mode 100644 packages/obsidian/src/ui/settings/runtime-settings-section.ts create mode 100644 packages/obsidian/src/ui/settings/stored-data-settings-section.ts create mode 100644 packages/obsidian/src/ui/settings/vault-scripts-settings-section.ts diff --git a/packages/obsidian/src/execution/worker-execution-session.ts b/packages/obsidian/src/execution/worker-execution-session.ts index f1f339a..dbde903 100644 --- a/packages/obsidian/src/execution/worker-execution-session.ts +++ b/packages/obsidian/src/execution/worker-execution-session.ts @@ -110,52 +110,7 @@ export class WorkerExecutionSession { cleanupCallbacks.push( worker.onMessage(message => { - const parsedMessage = workerToHostMessageSchema.safeParse(message); - if (!parsedMessage.success) { - settle({ - status: 'validation-error', - codeHash: options.codeHash, - message: 'Worker sent an invalid message.', - permissions: [...options.grantedPermissions], - elapsedMs: this.now() - options.startedAt, - }); - return; - } - - if (parsedMessage.data.executionId !== executionId) { - settle({ - status: 'validation-error', - codeHash: options.codeHash, - message: 'Worker sent a message for an unknown execution.', - permissions: [...options.grantedPermissions], - elapsedMs: this.now() - options.startedAt, - }); - return; - } - - if (parsedMessage.data.type === 'rpc-request') { - this.handleRpcRequest(parsedMessage.data, executionId, options, abortController, () => settled, worker.postMessage.bind(worker)); - return; - } - - if (parsedMessage.data.ok) { - settle({ - status: 'success', - codeHash: options.codeHash, - value: parsedMessage.data.value, - permissions: [...options.grantedPermissions], - elapsedMs: this.now() - options.startedAt, - }); - return; - } - - settle({ - status: 'runtime-error', - codeHash: options.codeHash, - message: parsedMessage.data.error.message, - permissions: [...options.grantedPermissions], - elapsedMs: this.now() - options.startedAt, - }); + this.handleWorkerMessage(message, executionId, options, abortController, () => settled, worker.postMessage.bind(worker), settle); }), worker.onError(error => { settle({ @@ -178,6 +133,51 @@ export class WorkerExecutionSession { }); } + private handleWorkerMessage( + message: unknown, + executionId: string, + options: WorkerExecutionSessionOptions, + abortController: AbortController, + isSettled: () => boolean, + postMessage: (message: HostRpcResponseMessage) => void, + settle: (result: SafeJsExecutionResult) => void, + ): void { + const parsedMessage = workerToHostMessageSchema.safeParse(message); + if (!parsedMessage.success) { + settle(this.createValidationErrorResult(options, 'Worker sent an invalid message.')); + return; + } + + if (parsedMessage.data.executionId !== executionId) { + settle(this.createValidationErrorResult(options, 'Worker sent a message for an unknown execution.')); + return; + } + + if (parsedMessage.data.type === 'rpc-request') { + this.handleRpcRequest(parsedMessage.data, executionId, options, abortController, isSettled, postMessage); + return; + } + + if (parsedMessage.data.ok) { + settle({ + status: 'success', + codeHash: options.codeHash, + value: parsedMessage.data.value, + permissions: [...options.grantedPermissions], + elapsedMs: this.now() - options.startedAt, + }); + return; + } + + settle({ + status: 'runtime-error', + codeHash: options.codeHash, + message: parsedMessage.data.error.message, + permissions: [...options.grantedPermissions], + elapsedMs: this.now() - options.startedAt, + }); + } + private handleRpcRequest( rpcMessage: HostRpcRequestMessage, executionId: string, @@ -245,4 +245,14 @@ export class WorkerExecutionSession { elapsedMs: this.now() - startedAt, }; } + + private createValidationErrorResult(options: WorkerExecutionSessionOptions, message: string): SafeJsExecutionResult { + return { + status: 'validation-error', + codeHash: options.codeHash, + message, + permissions: [...options.grantedPermissions], + elapsedMs: this.now() - options.startedAt, + }; + } } diff --git a/packages/obsidian/src/rpc/rpc-registry-types.ts b/packages/obsidian/src/rpc/rpc-registry-types.ts new file mode 100644 index 0000000..33e5b91 --- /dev/null +++ b/packages/obsidian/src/rpc/rpc-registry-types.ts @@ -0,0 +1,89 @@ +import type { + JsonValue, + PermissionDefinition, + PermissionId, + SafeJsValidationContext, + SafeJsValidationResult, + SafeJsValidator, + SafeJsValidatorReference, +} from '@lemons_dev/obsidian-safe-js-api'; +import type { WorkerRpcBinding } from '@lemons_dev/obsidian-safe-js-api/internal'; +import type { BuiltInValidatorOptions } from 'packages/obsidian/src/rpc/validators'; + +export interface RpcContext { + grantedPermissions: ReadonlySet; + codeHash?: string; + signal?: AbortSignal; +} + +export interface RpcDispatchSuccess { + ok: true; + result: JsonValue; +} + +export interface RpcDispatchFailure { + ok: false; + error: { + code: string; + message: string; + }; +} + +export type RpcDispatchResult = RpcDispatchSuccess | RpcDispatchFailure; + +export interface RpcMethodDefinition { + method: string; + permission: PermissionId; + description: string; + usage: string; + requestValidator: SafeJsValidatorReference; + responseValidator: SafeJsValidatorReference; + binding: Omit; + handler(params: TParams, context: RpcContext): Promise | TResult; +} + +export interface RpcRegistrationOwner { + pluginId?: string; + pluginName?: string; +} + +export interface SandboxGlobalRegistration { + name: string; + description: string; + value: JsonValue; + permission?: PermissionId; +} + +export interface RpcDocsMethod { + method: string; + apiPath: string; + description: string; + usage: string; + permission: PermissionId; + ownerPluginId?: string; + ownerPluginName?: string; +} + +export interface RpcDocsPermission { + permission: PermissionDefinition; + methods: RpcDocsMethod[]; + globals: RpcDocsGlobal[]; + ownerPluginId?: string; + ownerPluginName?: string; +} + +export interface RpcDocsGlobal { + name: string; + description: string; + permission?: PermissionId; + ownerPluginId?: string; + ownerPluginName?: string; +} + +export interface RpcRegistryOptions { + methods?: readonly RpcMethodDefinition[]; + permissionDefinitions?: readonly PermissionDefinition[]; + validators: BuiltInValidatorOptions | readonly SafeJsValidator[]; +} + +export type { JsonValue, PermissionDefinition, PermissionId, SafeJsValidationContext, SafeJsValidationResult, SafeJsValidator, SafeJsValidatorReference }; diff --git a/packages/obsidian/src/rpc/rpc-registry.ts b/packages/obsidian/src/rpc/rpc-registry.ts index 17d48eb..9b547a5 100644 --- a/packages/obsidian/src/rpc/rpc-registry.ts +++ b/packages/obsidian/src/rpc/rpc-registry.ts @@ -1,5 +1,4 @@ import type { - JsonValue, PermissionDefinition, PermissionId, SafeJsRegistration, @@ -11,80 +10,33 @@ import type { import type { WorkerRpcBinding, WorkerSandboxGlobal } from '@lemons_dev/obsidian-safe-js-api/internal'; import { jsonValueSchema } from 'packages/obsidian/src/execution/contracts'; import { PERMISSION_DEFINITIONS, getPermissionDefinition } from 'packages/obsidian/src/permissions/permissions'; +import type { + JsonValue, + RpcContext, + RpcDispatchResult, + RpcDocsPermission, + RpcMethodDefinition, + RpcRegistrationOwner, + RpcRegistryOptions, + SandboxGlobalRegistration, +} from 'packages/obsidian/src/rpc/rpc-registry-types'; import type { BuiltInValidatorOptions } from 'packages/obsidian/src/rpc/validators'; import { ValidatorRegistry, createBuiltInValidators } from 'packages/obsidian/src/rpc/validators'; export type { SafeJsRegistration } from '@lemons_dev/obsidian-safe-js-api'; - -export interface RpcContext { - grantedPermissions: ReadonlySet; - codeHash?: string; - signal?: AbortSignal; -} - -export interface RpcDispatchSuccess { - ok: true; - result: JsonValue; -} - -export interface RpcDispatchFailure { - ok: false; - error: { - code: string; - message: string; - }; -} - -export type RpcDispatchResult = RpcDispatchSuccess | RpcDispatchFailure; - -export interface RpcMethodDefinition { - method: string; - permission: PermissionId; - description: string; - usage: string; - requestValidator: SafeJsValidatorReference; - responseValidator: SafeJsValidatorReference; - binding: Omit; - handler(params: TParams, context: RpcContext): Promise | TResult; -} - -export interface RpcRegistrationOwner { - pluginId?: string; - pluginName?: string; -} - -export interface SandboxGlobalRegistration { - name: string; - description: string; - value: JsonValue; - permission?: PermissionId; -} - -export interface RpcDocsMethod { - method: string; - apiPath: string; - description: string; - usage: string; - permission: PermissionId; - ownerPluginId?: string; - ownerPluginName?: string; -} - -export interface RpcDocsPermission { - permission: PermissionDefinition; - methods: RpcDocsMethod[]; - globals: RpcDocsGlobal[]; - ownerPluginId?: string; - ownerPluginName?: string; -} - -export interface RpcDocsGlobal { - name: string; - description: string; - permission?: PermissionId; - ownerPluginId?: string; - ownerPluginName?: string; -} +export type { + RpcContext, + RpcDispatchFailure, + RpcDispatchResult, + RpcDispatchSuccess, + RpcDocsGlobal, + RpcDocsMethod, + RpcDocsPermission, + RpcMethodDefinition, + RpcRegistrationOwner, + RpcRegistryOptions, + SandboxGlobalRegistration, +} from 'packages/obsidian/src/rpc/rpc-registry-types'; export class RpcRegistry { private readonly methods = new Map>(); @@ -95,23 +47,19 @@ export class RpcRegistry { private readonly sandboxGlobals = new Map(); private readonly sandboxGlobalOwners = new Map(); - constructor( - methods: readonly RpcMethodDefinition[] = [], - permissionDefinitions: readonly PermissionDefinition[] = PERMISSION_DEFINITIONS, - validatorOptions: BuiltInValidatorOptions | readonly SafeJsValidator[], - ) { - this.validators = new ValidatorRegistry(isValidatorList(validatorOptions) ? validatorOptions : createBuiltInValidators(validatorOptions)); + constructor(options: RpcRegistryOptions) { + this.validators = new ValidatorRegistry(isValidatorList(options.validators) ? options.validators : createBuiltInValidators(options.validators)); - for (const permission of permissionDefinitions) { + for (const permission of options.permissionDefinitions ?? PERMISSION_DEFINITIONS) { this.permissionDefinitions.set(permission.id, permission); } - for (const method of methods) { - this.register(method); + for (const method of options.methods ?? []) { + this.addMethod(method); } } - register(method: RpcMethodDefinition, owner: RpcRegistrationOwner = {}): void { + private addMethod(method: RpcMethodDefinition, owner: RpcRegistrationOwner = {}): void { if (this.methods.has(method.method)) { throw new Error(`Duplicate RPC method '${method.method}'.`); } @@ -162,7 +110,7 @@ export class RpcRegistry { } registerMethod(method: RpcMethodDefinition, owner: RpcRegistrationOwner = {}): SafeJsRegistration { - this.register(method, owner); + this.addMethod(method, owner); return { unregister: (): void => { diff --git a/packages/obsidian/src/rpc/safe-js-rpc.ts b/packages/obsidian/src/rpc/safe-js-rpc.ts index 4cba8ba..0307a39 100644 --- a/packages/obsidian/src/rpc/safe-js-rpc.ts +++ b/packages/obsidian/src/rpc/safe-js-rpc.ts @@ -15,8 +15,8 @@ import { createWorkspaceMethods } from 'packages/obsidian/src/rpc/obsidian/works import { RpcRegistry } from 'packages/obsidian/src/rpc/rpc-registry'; export function createSafeJsRpcRegistry(app: App): RpcRegistry { - return new RpcRegistry( - [ + return new RpcRegistry({ + methods: [ ...createCoreMethods(app), ...createHelperMethods(), ...createVaultReadMethods(app), @@ -32,7 +32,6 @@ export function createSafeJsRpcRegistry(app: App): RpcRegistry { ...createNetworkMethods(), ...createStorageMethods(app), ], - undefined, - { getConfigDir: () => app.vault.configDir }, - ); + validators: { getConfigDir: () => app.vault.configDir }, + }); } diff --git a/packages/obsidian/src/scripts/script-settings.ts b/packages/obsidian/src/scripts/script-settings.ts index 4f4dec6..5e40f91 100644 --- a/packages/obsidian/src/scripts/script-settings.ts +++ b/packages/obsidian/src/scripts/script-settings.ts @@ -99,7 +99,7 @@ function uniqueScriptId(baseId: string, existingIds: readonly string[]): string return `${baseId}-${index}`; } -function displayNameFromPath(path: string): string { +export function displayNameFromPath(path: string): string { const fileName = path.split('/').pop() ?? path; return fileName.replace(/\.js$/iu, '') || 'Script'; } diff --git a/packages/obsidian/src/settings.ts b/packages/obsidian/src/settings.ts index 7108d3c..b247260 100644 --- a/packages/obsidian/src/settings.ts +++ b/packages/obsidian/src/settings.ts @@ -1,336 +1,3 @@ -import type { App } from 'obsidian'; -import { Notice, PluginSettingTab, Setting } from 'obsidian'; -import type SafeJsPlugin from 'packages/obsidian/src/main'; -import type { PermissionApproval, PermissionSettingsStore } from 'packages/obsidian/src/permissions/approval-store'; -import { - AppPermissionStorage, - LocalStoragePermissionApprovalStore, - LocalStoragePermissionSettingsStore, -} from 'packages/obsidian/src/permissions/approval-store'; -import type { SafeJsScriptConfig } from 'packages/obsidian/src/scripts/script-settings'; -import { createScriptConfig, isJavaScriptVaultScriptPath, normalizeSafeJsScripts } from 'packages/obsidian/src/scripts/script-settings'; -import type { VaultScriptManager } from 'packages/obsidian/src/scripts/vault-script-manager'; -import type { ScriptStorageEntry } from 'packages/obsidian/src/storage/script-storage'; -import { ScriptStorageManager } from 'packages/obsidian/src/storage/script-storage'; - -export interface SafeJsSettings { - executionTimeoutsEnabled: boolean; - executionTimeoutMs: number; - debugBlocksEnabled: boolean; - scripts: SafeJsScriptConfig[]; -} - -export const DEFAULT_SETTINGS: SafeJsSettings = { - executionTimeoutsEnabled: true, - executionTimeoutMs: 5000, - debugBlocksEnabled: true, - scripts: [], -}; - -export class SafeJsSettingTab extends PluginSettingTab { - plugin: SafeJsPlugin; - private readonly approvalStore: LocalStoragePermissionApprovalStore; - private readonly permissionSettingsStore: PermissionSettingsStore; - private readonly scriptManager?: VaultScriptManager; - private readonly staleEntryAgeMs = 30 * 24 * 60 * 60 * 1000; - - constructor( - app: App, - plugin: SafeJsPlugin, - permissionSettingsStore: PermissionSettingsStore = new LocalStoragePermissionSettingsStore(new AppPermissionStorage(app)), - scriptManager?: VaultScriptManager, - ) { - super(app, plugin); - this.plugin = plugin; - this.approvalStore = new LocalStoragePermissionApprovalStore(new AppPermissionStorage(app)); - this.permissionSettingsStore = permissionSettingsStore; - this.scriptManager = scriptManager; - } - - display(): void { - const { containerEl } = this; - - containerEl.empty(); - - new Setting(containerEl) - .setName('Execution timeouts') - .setDesc('Cancel scripts that run longer than the configured timeout.') - .addToggle(toggle => - toggle.setValue(this.plugin.settings.executionTimeoutsEnabled).onChange(async value => { - this.plugin.settings.executionTimeoutsEnabled = value; - await this.plugin.saveSettings(); - this.display(); - }), - ); - - new Setting(containerEl) - .setName('Execution timeout') - .setDesc( - this.plugin.settings.executionTimeoutsEnabled - ? 'Maximum time a worker-backed script may run before it is cancelled.' - : 'Timeouts are disabled. This value is kept for later use.', - ) - .addText(text => - text - .setPlaceholder(String(DEFAULT_SETTINGS.executionTimeoutMs)) - .setValue(String(this.plugin.settings.executionTimeoutMs)) - .onChange(async value => { - const parsedValue = Number.parseInt(value, 10); - this.plugin.settings.executionTimeoutMs = - Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : DEFAULT_SETTINGS.executionTimeoutMs; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName('Debug blocks') - .setDesc('Enable support for the safe-js-debug code block language.') - .addToggle(toggle => - toggle.setValue(this.plugin.settings.debugBlocksEnabled).onChange(async value => { - this.plugin.settings.debugBlocksEnabled = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(containerEl) - .setName('Auto-allow low-risk permissions') - .setDesc('Allow low-risk permissions without prompting. Approvals are still remembered per script hash on this device.') - .addToggle(toggle => - toggle.setValue(this.permissionSettingsStore.loadAutoAllowLowRiskPermissions()).onChange(value => { - this.permissionSettingsStore.saveAutoAllowLowRiskPermissions(value); - }), - ); - - this.renderVaultScripts(containerEl); - this.renderApprovalStorage(containerEl); - this.renderScriptStorage(containerEl); - } - - private renderVaultScripts(containerEl: HTMLElement): void { - const section = containerEl.createEl('section'); - new Setting(section).setName('Vault scripts').setHeading(); - section.createEl('p', { - text: `${this.plugin.settings.scripts.length} vault ${this.plugin.settings.scripts.length === 1 ? 'script is' : 'scripts are'} configured.`, - }); - - for (const script of this.plugin.settings.scripts) { - this.renderVaultScript(section, script); - } - - this.renderAddVaultScript(section); - } - - private renderVaultScript(section: HTMLElement, script: SafeJsScriptConfig): void { - new Setting(section) - .setName(script.name) - .setDesc(script.path) - .addText(text => - text - .setPlaceholder('Script name') - .setValue(script.name) - .onChange(async value => { - script.name = value.trim() || script.path; - await this.saveScriptSettings(); - }), - ) - .addText(text => - text - .setPlaceholder('Scripts/example.js') - .setValue(script.path) - .onChange(async value => { - script.path = value.trim(); - await this.saveScriptSettings(); - }), - ) - .addToggle(toggle => - toggle - .setTooltip('Run on startup') - .setValue(script.runOnStartup) - .onChange(async value => { - script.runOnStartup = value; - await this.saveScriptSettings(); - }), - ) - .addButton(button => - button.setButtonText('Remove').onClick(async () => { - this.plugin.settings.scripts = this.plugin.settings.scripts.filter(candidate => candidate.id !== script.id); - await this.saveScriptSettings(); - this.display(); - }), - ); - } - - private renderAddVaultScript(section: HTMLElement): void { - let path = ''; - let name = ''; - - new Setting(section) - .setName('Add script') - .setDesc('Configure a vault .js file as a command.') - .addText(text => - text.setPlaceholder('Scripts/example.js').onChange(value => { - path = value; - }), - ) - .addText(text => - text.setPlaceholder('Command name').onChange(value => { - name = value; - }), - ) - .addButton(button => - button.setButtonText('Add').onClick(async () => { - const normalizedPath = path.trim(); - if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) { - new Notice('Enter a vault path ending in .js.'); - return; - } - - if (this.plugin.settings.scripts.some(script => script.path === normalizedPath)) { - new Notice('That script is already configured.'); - return; - } - - this.plugin.settings.scripts = [...this.plugin.settings.scripts, createScriptConfig(normalizedPath, name, this.plugin.settings.scripts)]; - await this.saveScriptSettings(); - this.display(); - }), - ); - } - - private async saveScriptSettings(): Promise { - await this.plugin.saveSettings(); - this.scriptManager?.reloadCommands(); - } - - private renderApprovalStorage(containerEl: HTMLElement): void { - const approvals = this.approvalStore.list(); - const section = containerEl.createEl('section'); - new Setting(section).setName('Approved script hashes').setHeading(); - section.createEl('p', { - text: `${approvals.length} approved script ${approvals.length === 1 ? 'hash' : 'hashes'} are stored on this device.`, - }); - - new Setting(section) - .setName('Clear approved hashes') - .setDesc('Remove remembered permission approvals for changed or old scripts.') - .addButton(button => - button.setButtonText('Clear older than 30 days').onClick(() => { - const deletedCount = this.approvalStore.deleteOlderThan(Date.now() - this.staleEntryAgeMs); - new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`); - this.display(); - }), - ) - .addButton(button => - button.setButtonText('Clear all').onClick(() => { - const deletedCount = this.approvalStore.deleteAll(); - new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`); - this.display(); - }), - ); - - this.renderApprovalList(section, approvals); - } - - private renderApprovalList(section: HTMLElement, approvals: PermissionApproval[]): void { - if (approvals.length === 0) { - section.createEl('p', { text: 'No approved script hashes are stored.' }); - return; - } - - const list = section.createEl('ul'); - for (const approval of approvals.slice(0, 20)) { - const item = list.createEl('li'); - item.createEl('code', { text: approval.codeHash }); - item.createSpan({ - text: ` - ${formatCaller(approval.callerPluginId)} - ${approval.permissions.join(', ')} - ${formatDate(approval.updatedAt)}`, - }); - } - - if (approvals.length > 20) { - section.createEl('p', { text: `${approvals.length - 20} more approved hashes are hidden.` }); - } - } - - private renderScriptStorage(containerEl: HTMLElement): void { - const entries = ScriptStorageManager.listAll(this.app); - const section = containerEl.createEl('section'); - new Setting(section).setName('Script storage').setHeading(); - section.createEl('p', { - text: `${entries.length} Safe JS storage ${entries.length === 1 ? 'key is' : 'keys are'} indexed on this device.`, - }); - - new Setting(section) - .setName('Clear script storage') - .setDesc('Remove data written through the storage API by scripts.') - .addButton(button => - button.setButtonText('Clear older than 30 days').onClick(() => { - const deletedCount = ScriptStorageManager.deleteOlderThanAll(this.app, Date.now() - this.staleEntryAgeMs); - new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`); - this.display(); - }), - ) - .addButton(button => - button.setButtonText('Clear all').onClick(() => { - const deletedCount = ScriptStorageManager.deleteAllKnown(this.app); - new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`); - this.display(); - }), - ); - - this.renderScriptStorageList(section, entries); - } - - private renderScriptStorageList(section: HTMLElement, entries: ScriptStorageEntry[]): void { - if (entries.length === 0) { - section.createEl('p', { text: 'No indexed script storage keys are stored.' }); - return; - } - - const list = section.createEl('ul'); - for (const entry of entries.slice(0, 20)) { - const item = list.createEl('li'); - item.createEl('code', { text: entry.key }); - item.createSpan({ text: ` - ${formatScope(entry.scope)} - ${formatBytes(entry.sizeBytes)} - ${formatDate(entry.updatedAt)}` }); - } - - if (entries.length > 20) { - section.createEl('p', { text: `${entries.length - 20} more storage keys are hidden.` }); - } - } -} - -function formatDate(timestamp: number): string { - return new Date(timestamp).toLocaleString(); -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B`; - } - - return `${(bytes / 1024).toFixed(1)} KB`; -} - -function formatCaller(callerPluginId: string | undefined): string { - return callerPluginId ?? 'notes'; -} - -function formatScope(scope: string | null): string { - return scope === null ? 'global' : `scoped ${scope.slice(0, 12)}`; -} - -export function normalizeSafeJsSettings(value: unknown): SafeJsSettings { - const record = typeof value === 'object' && value !== null ? (value as Record) : {}; - const executionTimeoutMs = - typeof record.executionTimeoutMs === 'number' && Number.isFinite(record.executionTimeoutMs) && record.executionTimeoutMs > 0 - ? record.executionTimeoutMs - : DEFAULT_SETTINGS.executionTimeoutMs; - - return { - executionTimeoutsEnabled: - typeof record.executionTimeoutsEnabled === 'boolean' ? record.executionTimeoutsEnabled : DEFAULT_SETTINGS.executionTimeoutsEnabled, - executionTimeoutMs, - debugBlocksEnabled: typeof record.debugBlocksEnabled === 'boolean' ? record.debugBlocksEnabled : DEFAULT_SETTINGS.debugBlocksEnabled, - scripts: normalizeSafeJsScripts(record.scripts), - }; -} +export { DEFAULT_SETTINGS, normalizeSafeJsSettings } from 'packages/obsidian/src/settings/settings-schema'; +export type { SafeJsSettings } from 'packages/obsidian/src/settings/settings-schema'; +export { SafeJsSettingTab } from 'packages/obsidian/src/ui/settings-tab'; diff --git a/packages/obsidian/src/settings/settings-schema.ts b/packages/obsidian/src/settings/settings-schema.ts new file mode 100644 index 0000000..8d3f5b6 --- /dev/null +++ b/packages/obsidian/src/settings/settings-schema.ts @@ -0,0 +1,32 @@ +import type { SafeJsScriptConfig } from 'packages/obsidian/src/scripts/script-settings'; +import { normalizeSafeJsScripts } from 'packages/obsidian/src/scripts/script-settings'; + +export interface SafeJsSettings { + executionTimeoutsEnabled: boolean; + executionTimeoutMs: number; + debugBlocksEnabled: boolean; + scripts: SafeJsScriptConfig[]; +} + +export const DEFAULT_SETTINGS: SafeJsSettings = { + executionTimeoutsEnabled: true, + executionTimeoutMs: 5000, + debugBlocksEnabled: true, + scripts: [], +}; + +export function normalizeSafeJsSettings(value: unknown): SafeJsSettings { + const record = typeof value === 'object' && value !== null ? (value as Record) : {}; + const executionTimeoutMs = + typeof record.executionTimeoutMs === 'number' && Number.isFinite(record.executionTimeoutMs) && record.executionTimeoutMs > 0 + ? record.executionTimeoutMs + : DEFAULT_SETTINGS.executionTimeoutMs; + + return { + executionTimeoutsEnabled: + typeof record.executionTimeoutsEnabled === 'boolean' ? record.executionTimeoutsEnabled : DEFAULT_SETTINGS.executionTimeoutsEnabled, + executionTimeoutMs, + debugBlocksEnabled: typeof record.debugBlocksEnabled === 'boolean' ? record.debugBlocksEnabled : DEFAULT_SETTINGS.debugBlocksEnabled, + scripts: normalizeSafeJsScripts(record.scripts), + }; +} diff --git a/packages/obsidian/src/ui/add-vault-script-modal.ts b/packages/obsidian/src/ui/add-vault-script-modal.ts new file mode 100644 index 0000000..eff60e8 --- /dev/null +++ b/packages/obsidian/src/ui/add-vault-script-modal.ts @@ -0,0 +1,118 @@ +import type { App } from 'obsidian'; +import type { TextComponent } from 'obsidian'; +import { Modal, Notice, Setting } from 'obsidian'; +import { isJavaScriptVaultScriptPath } from 'packages/obsidian/src/scripts/script-settings'; + +export interface AddVaultScriptModalValues { + name: string; + path: string; + runOnStartup: boolean; +} + +export interface AddVaultScriptModalSubmitResult { + message?: string; + saved: boolean; +} + +export interface AddVaultScriptModalOptions { + actionText: string; + initialValues: AddVaultScriptModalValues; + title: string; +} + +export class AddVaultScriptModal extends Modal { + private readonly actionText: string; + private readonly onSubmit: (values: AddVaultScriptModalValues) => Promise; + private readonly title: string; + private path: string; + private name: string; + private runOnStartup: boolean; + + constructor(app: App, options: AddVaultScriptModalOptions, onSubmit: (values: AddVaultScriptModalValues) => Promise) { + super(app); + this.actionText = options.actionText; + this.name = options.initialValues.name; + this.onSubmit = onSubmit; + this.path = options.initialValues.path; + this.runOnStartup = options.initialValues.runOnStartup; + this.title = options.title; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl('h2', { text: this.title }); + let pathInput: TextComponent | undefined; + + new Setting(contentEl) + .setName('Vault path') + .setDesc('Use a vault-relative path that ends in .js.') + .addText(text => { + pathInput = text; + return text + .setPlaceholder('Scripts/example.js') + .setValue(this.path) + .onChange(value => { + this.path = value; + }); + }); + + new Setting(contentEl) + .setName('Command name') + .setDesc('Leave blank to use the file name.') + .addText(text => + text + .setPlaceholder('Example script') + .setValue(this.name) + .onChange(value => { + this.name = value; + }), + ); + + new Setting(contentEl) + .setName('Run on startup') + .setDesc('Run this script when Obsidian finishes loading.') + .addToggle(toggle => + toggle.setValue(this.runOnStartup).onChange(value => { + this.runOnStartup = value; + }), + ); + + new Setting(contentEl) + .addButton(button => + button.setButtonText('Cancel').onClick(() => { + this.close(); + }), + ) + .addButton(button => + button + .setButtonText(this.actionText) + .setCta() + .onClick(() => { + void this.submit(); + }), + ); + + pathInput?.inputEl.focus(); + } + + private async submit(): Promise { + const normalizedPath = this.path.trim(); + if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) { + new Notice('Enter a vault path ending in .js.'); + return; + } + + const result = await this.onSubmit({ + name: this.name, + path: normalizedPath, + runOnStartup: this.runOnStartup, + }); + if (result.message !== undefined) { + new Notice(result.message); + } + if (result.saved) { + this.close(); + } + } +} diff --git a/packages/obsidian/src/ui/settings-tab.ts b/packages/obsidian/src/ui/settings-tab.ts new file mode 100644 index 0000000..be910eb --- /dev/null +++ b/packages/obsidian/src/ui/settings-tab.ts @@ -0,0 +1,53 @@ +import type { App } from 'obsidian'; +import { PluginSettingTab } from 'obsidian'; +import type SafeJsPlugin from 'packages/obsidian/src/main'; +import type { PermissionSettingsStore } from 'packages/obsidian/src/permissions/approval-store'; +import { + AppPermissionStorage, + LocalStoragePermissionApprovalStore, + LocalStoragePermissionSettingsStore, +} from 'packages/obsidian/src/permissions/approval-store'; +import type { VaultScriptManager } from 'packages/obsidian/src/scripts/vault-script-manager'; +import { RuntimeSettingsSection } from 'packages/obsidian/src/ui/settings/runtime-settings-section'; +import { StoredDataSettingsSection } from 'packages/obsidian/src/ui/settings/stored-data-settings-section'; +import { VaultScriptsSettingsSection } from 'packages/obsidian/src/ui/settings/vault-scripts-settings-section'; + +export class SafeJsSettingTab extends PluginSettingTab { + plugin: SafeJsPlugin; + private readonly approvalStore: LocalStoragePermissionApprovalStore; + private readonly permissionSettingsStore: PermissionSettingsStore; + private readonly scriptManager?: VaultScriptManager; + + constructor( + app: App, + plugin: SafeJsPlugin, + permissionSettingsStore: PermissionSettingsStore = new LocalStoragePermissionSettingsStore(new AppPermissionStorage(app)), + scriptManager?: VaultScriptManager, + ) { + super(app, plugin); + this.plugin = plugin; + this.approvalStore = new LocalStoragePermissionApprovalStore(new AppPermissionStorage(app)); + this.permissionSettingsStore = permissionSettingsStore; + this.scriptManager = scriptManager; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new RuntimeSettingsSection(this.plugin, this.permissionSettingsStore, () => { + this.display(); + }).render(containerEl); + new VaultScriptsSettingsSection( + this.app, + this.plugin, + () => { + this.display(); + }, + this.scriptManager, + ).render(containerEl); + new StoredDataSettingsSection(this.app, this.approvalStore, () => { + this.display(); + }).render(containerEl); + } +} diff --git a/packages/obsidian/src/ui/settings/runtime-settings-section.ts b/packages/obsidian/src/ui/settings/runtime-settings-section.ts new file mode 100644 index 0000000..37da55b --- /dev/null +++ b/packages/obsidian/src/ui/settings/runtime-settings-section.ts @@ -0,0 +1,78 @@ +import { Setting } from 'obsidian'; +import type SafeJsPlugin from 'packages/obsidian/src/main'; +import type { PermissionSettingsStore } from 'packages/obsidian/src/permissions/approval-store'; +import type { SafeJsSettings } from 'packages/obsidian/src/settings/settings-schema'; +import { DEFAULT_SETTINGS } from 'packages/obsidian/src/settings/settings-schema'; + +export class RuntimeSettingsSection { + private readonly onSettingsChanged: () => void; + private readonly permissionSettingsStore: PermissionSettingsStore; + private readonly plugin: SafeJsPlugin; + + constructor(plugin: SafeJsPlugin, permissionSettingsStore: PermissionSettingsStore, onSettingsChanged: () => void) { + this.onSettingsChanged = onSettingsChanged; + this.permissionSettingsStore = permissionSettingsStore; + this.plugin = plugin; + } + + render(containerEl: HTMLElement): void { + this.createSection(containerEl, 'Runtime settings'); + new Setting(containerEl) + .setName('Execution timeouts') + .setDesc('Cancel scripts that run longer than the configured timeout.') + .addToggle(toggle => + toggle.setValue(this.plugin.settings.executionTimeoutsEnabled).onChange(async value => { + this.plugin.settings.executionTimeoutsEnabled = value; + await this.plugin.saveSettings(); + this.onSettingsChanged(); + }), + ); + + new Setting(containerEl) + .setName('Execution timeout') + .setDesc(this.executionTimeoutDescription(this.plugin.settings)) + .addText(text => + text + .setPlaceholder(String(DEFAULT_SETTINGS.executionTimeoutMs)) + .setValue(String(this.plugin.settings.executionTimeoutMs)) + .onChange(async value => { + this.plugin.settings.executionTimeoutMs = this.normalizeTimeoutMs(value); + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName('Debug blocks') + .setDesc('Enable support for the safe-js-debug code block language.') + .addToggle(toggle => + toggle.setValue(this.plugin.settings.debugBlocksEnabled).onChange(async value => { + this.plugin.settings.debugBlocksEnabled = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName('Auto-allow low-risk permissions') + .setDesc('Allow low-risk permissions without prompting. Approvals are still remembered per script hash on this device.') + .addToggle(toggle => + toggle.setValue(this.permissionSettingsStore.loadAutoAllowLowRiskPermissions()).onChange(value => { + this.permissionSettingsStore.saveAutoAllowLowRiskPermissions(value); + }), + ); + } + + private createSection(containerEl: HTMLElement, heading: string): void { + new Setting(containerEl).setName(heading).setHeading(); + } + + private executionTimeoutDescription(settings: SafeJsSettings): string { + return settings.executionTimeoutsEnabled + ? 'Maximum time a worker-backed script may run before it is cancelled.' + : 'Timeouts are disabled. This value is kept for later use.'; + } + + private normalizeTimeoutMs(value: string): number { + const parsedValue = Number.parseInt(value, 10); + return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : DEFAULT_SETTINGS.executionTimeoutMs; + } +} diff --git a/packages/obsidian/src/ui/settings/stored-data-settings-section.ts b/packages/obsidian/src/ui/settings/stored-data-settings-section.ts new file mode 100644 index 0000000..874eb18 --- /dev/null +++ b/packages/obsidian/src/ui/settings/stored-data-settings-section.ts @@ -0,0 +1,149 @@ +import type { App } from 'obsidian'; +import { Notice, Setting, SettingGroup } from 'obsidian'; +import type { PermissionApproval } from 'packages/obsidian/src/permissions/approval-store'; +import type { LocalStoragePermissionApprovalStore } from 'packages/obsidian/src/permissions/approval-store'; +import type { ScriptStorageEntry } from 'packages/obsidian/src/storage/script-storage'; +import { ScriptStorageManager } from 'packages/obsidian/src/storage/script-storage'; + +export class StoredDataSettingsSection { + private readonly app: App; + private readonly approvalStore: LocalStoragePermissionApprovalStore; + private readonly onStorageChanged: () => void; + private readonly staleEntryAgeMs = 30 * 24 * 60 * 60 * 1000; + + constructor(app: App, approvalStore: LocalStoragePermissionApprovalStore, onStorageChanged: () => void) { + this.app = app; + this.approvalStore = approvalStore; + this.onStorageChanged = onStorageChanged; + } + + render(containerEl: HTMLElement): void { + this.renderApprovalStorage(containerEl); + this.renderScriptStorage(containerEl); + } + + private renderApprovalStorage(containerEl: HTMLElement): void { + const approvals = this.approvalStore.list(); + this.createSection(containerEl, 'Approved script hashes'); + + new Setting(containerEl) + .setName('Clear approved hashes') + .setDesc('Remove remembered permission approvals for changed or old scripts.') + .addButton(button => + button.setButtonText('Clear older than 30 days').onClick(() => { + const deletedCount = this.approvalStore.deleteOlderThan(Date.now() - this.staleEntryAgeMs); + new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`); + this.onStorageChanged(); + }), + ) + .addButton(button => + button.setButtonText('Clear all').onClick(() => { + const deletedCount = this.approvalStore.deleteAll(); + new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`); + this.onStorageChanged(); + }), + ); + + this.renderApprovalList(new SettingGroup(containerEl), approvals); + } + + private renderApprovalList(group: SettingGroup, approvals: PermissionApproval[]): void { + if (approvals.length === 0) { + group.addSetting(setting => { + setting.setName('No approved script hashes are stored.'); + }); + return; + } + + for (const approval of approvals.slice(0, 20)) { + group.addSetting(setting => { + setting + .setName(approval.codeHash) + .setDesc(`${formatCaller(approval.callerPluginId)} - ${approval.permissions.join(', ')} - ${formatDate(approval.updatedAt)}`) + .addButton(button => + button.setButtonText('Revoke').onClick(() => { + this.approvalStore.delete(approval); + new Notice(`Revoked approval for script hash ${approval.codeHash}.`); + this.onStorageChanged(); + }), + ); + }); + } + + if (approvals.length > 20) { + group.addSetting(setting => { + setting.setName(`${approvals.length - 20} more approved hashes are hidden.`); + }); + } + } + + private renderScriptStorage(containerEl: HTMLElement): void { + const entries = ScriptStorageManager.listAll(this.app); + this.createSection(containerEl, 'Script storage'); + + new Setting(containerEl) + .setName('Clear script storage') + .setDesc('Remove data written through the storage API by scripts.') + .addButton(button => + button.setButtonText('Clear older than 30 days').onClick(() => { + const deletedCount = ScriptStorageManager.deleteOlderThanAll(this.app, Date.now() - this.staleEntryAgeMs); + new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`); + this.onStorageChanged(); + }), + ) + .addButton(button => + button.setButtonText('Clear all').onClick(() => { + const deletedCount = ScriptStorageManager.deleteAllKnown(this.app); + new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`); + this.onStorageChanged(); + }), + ); + + this.renderScriptStorageList(new SettingGroup(containerEl), entries); + } + + private renderScriptStorageList(group: SettingGroup, entries: ScriptStorageEntry[]): void { + if (entries.length === 0) { + group.addSetting(setting => { + setting.setName('No indexed script storage keys are stored.'); + }); + return; + } + + for (const entry of entries.slice(0, 20)) { + group.addSetting(setting => { + setting.setName(entry.key).setDesc(`${formatScope(entry.scope)} - ${formatBytes(entry.sizeBytes)} - ${formatDate(entry.updatedAt)}`); + }); + } + + if (entries.length > 20) { + group.addSetting(setting => { + setting.setName(`${entries.length - 20} more storage keys are hidden.`); + }); + } + } + + private createSection(containerEl: HTMLElement, heading: string): void { + new Setting(containerEl).setName(heading).setHeading(); + } +} + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleString(); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + return `${(bytes / 1024).toFixed(1)} KB`; +} + +function formatCaller(callerPluginId: string | undefined): string { + return callerPluginId ?? 'notes'; +} + +function formatScope(scope: string | null): string { + return scope === null ? 'global' : `scoped ${scope.slice(0, 12)}`; +} diff --git a/packages/obsidian/src/ui/settings/vault-scripts-settings-section.ts b/packages/obsidian/src/ui/settings/vault-scripts-settings-section.ts new file mode 100644 index 0000000..02b554f --- /dev/null +++ b/packages/obsidian/src/ui/settings/vault-scripts-settings-section.ts @@ -0,0 +1,154 @@ +import type { App } from 'obsidian'; +import { Setting, SettingGroup } from 'obsidian'; +import type SafeJsPlugin from 'packages/obsidian/src/main'; +import type { SafeJsScriptConfig } from 'packages/obsidian/src/scripts/script-settings'; +import { createScriptConfig, displayNameFromPath, isJavaScriptVaultScriptPath } from 'packages/obsidian/src/scripts/script-settings'; +import type { VaultScriptManager } from 'packages/obsidian/src/scripts/vault-script-manager'; +import type { AddVaultScriptModalValues } from 'packages/obsidian/src/ui/add-vault-script-modal'; +import { AddVaultScriptModal } from 'packages/obsidian/src/ui/add-vault-script-modal'; + +export class VaultScriptsSettingsSection { + private readonly app: App; + private readonly onSettingsChanged: () => void; + private readonly plugin: SafeJsPlugin; + private readonly scriptManager?: VaultScriptManager; + + constructor(app: App, plugin: SafeJsPlugin, onSettingsChanged: () => void, scriptManager?: VaultScriptManager) { + this.app = app; + this.onSettingsChanged = onSettingsChanged; + this.plugin = plugin; + this.scriptManager = scriptManager; + } + + render(containerEl: HTMLElement): void { + this.createSection(containerEl, 'Vault scripts'); + const group = new SettingGroup(containerEl); + + for (const script of this.plugin.settings.scripts) { + this.renderVaultScript(group, script); + } + + this.renderAddVaultScript(group); + } + + private renderVaultScript(group: SettingGroup, script: SafeJsScriptConfig): void { + group.addSetting(setting => { + setting + .setName(script.name) + .setDesc(formatVaultScriptDescription(script)) + .addButton(button => + button.setButtonText('Edit').onClick(() => { + this.openEditVaultScriptModal(script); + }), + ) + .addButton(button => + button.setButtonText('Remove').onClick(() => { + void this.removeVaultScript(script); + }), + ); + }); + } + + private renderAddVaultScript(group: SettingGroup): void { + group.addSetting(setting => { + setting + .setName('Add script') + .setDesc('Configure a vault .js file as a command.') + .addButton(button => + button + .setButtonText('Add script') + .setCta() + .onClick(() => { + this.openAddVaultScriptModal(); + }), + ); + }); + } + + private openAddVaultScriptModal(): void { + new AddVaultScriptModal( + this.app, + { + actionText: 'Add', + initialValues: { + name: '', + path: '', + runOnStartup: false, + }, + title: 'Add script', + }, + async values => await this.addVaultScript(values), + ).open(); + } + + private async removeVaultScript(script: SafeJsScriptConfig): Promise { + this.plugin.settings.scripts = this.plugin.settings.scripts.filter(candidate => candidate.id !== script.id); + await this.saveScriptSettings(); + this.onSettingsChanged(); + } + + private openEditVaultScriptModal(script: SafeJsScriptConfig): void { + new AddVaultScriptModal( + this.app, + { + actionText: 'Save', + initialValues: { + name: script.name, + path: script.path, + runOnStartup: script.runOnStartup, + }, + title: 'Edit script', + }, + async values => await this.editVaultScript(script, values), + ).open(); + } + + private async addVaultScript(values: AddVaultScriptModalValues): Promise<{ message?: string; saved: boolean }> { + const normalizedPath = values.path.trim(); + if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) { + return { message: 'Enter a vault path ending in .js.', saved: false }; + } + + if (this.plugin.settings.scripts.some(script => script.path === normalizedPath)) { + return { message: 'That script is already configured.', saved: false }; + } + + const script = createScriptConfig(normalizedPath, values.name, this.plugin.settings.scripts); + script.runOnStartup = values.runOnStartup; + this.plugin.settings.scripts = [...this.plugin.settings.scripts, script]; + await this.saveScriptSettings(); + this.onSettingsChanged(); + return { saved: true }; + } + + private async editVaultScript(script: SafeJsScriptConfig, values: AddVaultScriptModalValues): Promise<{ message?: string; saved: boolean }> { + const normalizedPath = values.path.trim(); + if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) { + return { message: 'Enter a vault path ending in .js.', saved: false }; + } + + if (this.plugin.settings.scripts.some(candidate => candidate.id !== script.id && candidate.path === normalizedPath)) { + return { message: 'That script is already configured.', saved: false }; + } + + script.name = values.name.trim() || displayNameFromPath(normalizedPath); + script.path = normalizedPath; + script.runOnStartup = values.runOnStartup; + await this.saveScriptSettings(); + this.onSettingsChanged(); + return { saved: true }; + } + + private async saveScriptSettings(): Promise { + await this.plugin.saveSettings(); + this.scriptManager?.reloadCommands(); + } + + private createSection(containerEl: HTMLElement, heading: string): void { + new Setting(containerEl).setName(heading).setHeading(); + } +} + +function formatVaultScriptDescription(script: SafeJsScriptConfig): string { + return script.runOnStartup ? `${script.path} - Runs on startup` : script.path; +} diff --git a/packages/obsidian/src/worker/safe-js-worker.ts b/packages/obsidian/src/worker/safe-js-worker.ts index a29a496..429a22d 100644 --- a/packages/obsidian/src/worker/safe-js-worker.ts +++ b/packages/obsidian/src/worker/safe-js-worker.ts @@ -53,7 +53,11 @@ function normalizeParams(binding: WorkerRpcBinding, args: unknown[]): JsonValue } const params = args[0] ?? {}; - return isJsonValue(params) ? params : {}; + if (!isJsonValue(params)) { + throw new Error('RPC object parameters must be JSON-safe.'); + } + + return params; } function normalizeNamedArgs(binding: WorkerRpcBinding, args: unknown[]): JsonValue { @@ -62,7 +66,11 @@ function normalizeNamedArgs(binding: WorkerRpcBinding, args: unknown[]): JsonVal for (const [index, name] of argNames.entries()) { const value = args[index]; if (value !== undefined) { - params[name] = isJsonValue(value) ? value : null; + if (!isJsonValue(value)) { + throw new Error(`RPC argument '${name}' must be JSON-safe.`); + } + + params[name] = value; } } diff --git a/test/execution-service.test.ts b/test/execution-service.test.ts index 5ee9ebc..f706fd6 100644 --- a/test/execution-service.test.ts +++ b/test/execution-service.test.ts @@ -126,8 +126,8 @@ async function waitForWorker(workerFactory: { workers: unknown[] }): Promise params, }, ], - undefined, - testValidatorOptions, - ); + validators: testValidatorOptions, + }); } function createService(options: { promptApproved: boolean; store?: MemoryPermissionApprovalStore; workerFactory?: FakeWorkerFactory }) { @@ -175,8 +174,8 @@ function createLowRiskService(options: { autoAllowLowRiskPermissions: boolean }) const workerFactory = new FakeWorkerFactory(); const store = new MemoryPermissionApprovalStore(); const service = new SafeJsExecutionService({ - rpcRegistry: new RpcRegistry( - [ + rpcRegistry: new RpcRegistry({ + methods: [ { method: 'test:echo', permission: 'test:call', @@ -192,7 +191,7 @@ function createLowRiskService(options: { autoAllowLowRiskPermissions: boolean }) handler: params => params, }, ], - [ + permissionDefinitions: [ { id: 'test:call', name: 'Test calls', @@ -201,8 +200,8 @@ function createLowRiskService(options: { autoAllowLowRiskPermissions: boolean }) grantGuidance: 'Grant in tests.', }, ], - testValidatorOptions, - ), + validators: testValidatorOptions, + }), approvalStore: store, permissionPrompt: prompt, workerFactory, @@ -426,8 +425,8 @@ test('measures execution elapsed time after permission approval', async () => { })(true); const workerFactory = new FakeWorkerFactory(); const service = new SafeJsExecutionService({ - rpcRegistry: new RpcRegistry( - [ + rpcRegistry: new RpcRegistry({ + methods: [ { method: 'test:echo', permission: 'test:call', @@ -446,9 +445,8 @@ test('measures execution elapsed time after permission approval', async () => { }, }, ], - undefined, - testValidatorOptions, - ), + validators: testValidatorOptions, + }), approvalStore: new MemoryPermissionApprovalStore(), permissionPrompt: prompt, workerFactory, diff --git a/test/helper-rpc.test.ts b/test/helper-rpc.test.ts index 1b2cbe9..c52504a 100644 --- a/test/helper-rpc.test.ts +++ b/test/helper-rpc.test.ts @@ -45,7 +45,10 @@ mock.module('obsidian', () => ({ async function createRegistry(): Promise { const { createHelperMethods } = await import('packages/obsidian/src/rpc/obsidian/helper-rpc'); - return new RpcRegistry(createHelperMethods(), undefined, testValidatorOptions); + return new RpcRegistry({ + methods: createHelperMethods(), + validators: testValidatorOptions, + }); } test('registers helper methods under the helper permission', async () => { diff --git a/test/public-api.test.ts b/test/public-api.test.ts index ecf0870..6d5912e 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -84,7 +84,7 @@ function createPlugin(): { plugin: Plugin; unload(): void } { test('public caller API stamps execution source with caller plugin metadata', async () => { const executionService = new FakeExecutionService(); - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); const { plugin } = createPlugin(); const safeJsApi = new DefaultSafeJsPublicApi({ executionService: executionService as unknown as SafeJsExecutionService, @@ -106,7 +106,7 @@ test('public caller API stamps execution source with caller plugin metadata', as test('public caller API registers owned sandbox functions and cleans them up on unload', async () => { const executionService = new FakeExecutionService(); - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); const { plugin, unload } = createPlugin(); const callerApi = new DefaultSafeJsPublicApi({ executionService: executionService as unknown as SafeJsExecutionService, @@ -154,7 +154,7 @@ test('public caller API registers owned sandbox functions and cleans them up on test('public caller API rejects sandbox functions with unknown permissions', () => { const executionService = new FakeExecutionService(); - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); const { plugin } = createPlugin(); const callerApi = new DefaultSafeJsPublicApi({ executionService: executionService as unknown as SafeJsExecutionService, @@ -182,7 +182,7 @@ test('public caller API rejects sandbox functions with unknown permissions', () test('public caller API exposes built-in validator IDs without exposing zod', () => { const executionService = new FakeExecutionService(); - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); const { plugin } = createPlugin(); const callerApi = new DefaultSafeJsPublicApi({ executionService: executionService as unknown as SafeJsExecutionService, diff --git a/test/rpc-registry.test.ts b/test/rpc-registry.test.ts index e1046b6..3ecd4c8 100644 --- a/test/rpc-registry.test.ts +++ b/test/rpc-registry.test.ts @@ -20,8 +20,8 @@ function echoValueValidator(value: unknown): SafeJsValidationResult { } function createTestRegistry(): RpcRegistry { - return new RpcRegistry( - [ + return new RpcRegistry({ + methods: [ { method: 'test:echo', permission: 'test:call', @@ -38,9 +38,8 @@ function createTestRegistry(): RpcRegistry { handler: params => params, }, ], - undefined, - testValidatorOptions, - ); + validators: testValidatorOptions, + }); } test('dispatches valid RPC calls', async () => { @@ -94,8 +93,8 @@ test('exposes worker bindings with argument metadata', () => { }); test('generates docs from registered permission and method definitions', () => { - const registry = new RpcRegistry( - [ + const registry = new RpcRegistry({ + methods: [ { method: 'test:echo', permission: 'test:call', @@ -111,7 +110,7 @@ test('generates docs from registered permission and method definitions', () => { handler: params => params, }, ], - [ + permissionDefinitions: [ { id: 'test:call', name: 'Test calls', @@ -120,8 +119,8 @@ test('generates docs from registered permission and method definitions', () => { grantGuidance: 'Grant in tests.', }, ], - testValidatorOptions, - ); + validators: testValidatorOptions, + }); const docs = registry.getDocs(); expect(docs).toHaveLength(1); @@ -130,7 +129,7 @@ test('generates docs from registered permission and method definitions', () => { }); test('registers owned custom permissions, methods, and globals', () => { - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); registry.registerPermission( { id: 'plugin:call', @@ -201,7 +200,7 @@ test('registers owned custom permissions, methods, and globals', () => { }); test('exposes standalone built-in permissions', () => { - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); expect(registry.getKnownPermissions()).toContain('output:render-rich'); expect(registry.getDocs().find(group => group.permission.id === 'output:render-rich')).toMatchObject({ @@ -244,7 +243,7 @@ test('rejects duplicate sandbox API paths and reserved globals', () => { }); test('rejects sandbox globals with non-json values', () => { - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); expect(() => { registry.registerSandboxGlobal({ @@ -256,7 +255,7 @@ test('rejects sandbox globals with non-json values', () => { }); test('keeps owned permissions while sandbox globals still reference them', () => { - const registry = new RpcRegistry([], undefined, testValidatorOptions); + const registry = new RpcRegistry({ validators: testValidatorOptions }); registry.registerPermission( { id: 'plugin:shared', @@ -285,8 +284,8 @@ test('keeps owned permissions while sandbox globals still reference them', () => }); test('dispatches methods that reference built-in validators by id', async () => { - const registry = new RpcRegistry( - [ + const registry = new RpcRegistry({ + methods: [ { method: 'test:ping', permission: 'test:call', @@ -302,9 +301,8 @@ test('dispatches methods that reference built-in validators by id', async () => handler: () => ({ ok: true }), }, ], - undefined, - testValidatorOptions, - ); + validators: testValidatorOptions, + }); expect(await registry.dispatch('test:ping', {}, { grantedPermissions: new Set(['test:call']) })).toEqual({ ok: true, @@ -315,7 +313,7 @@ test('dispatches methods that reference built-in validators by id', async () => test('built-in vault path validators read the current config directory', () => { let configDir = '_config-a'; - const registry = new RpcRegistry([], undefined, createBuiltInValidators({ getConfigDir: () => configDir })); + const registry = new RpcRegistry({ validators: createBuiltInValidators({ getConfigDir: () => configDir }) }); expect(registry.validate('vault:path', '_config-a/plugins/safe-js/data.json')).toMatchObject({ success: false, @@ -338,8 +336,8 @@ test('built-in vault path validators read the current config directory', () => { test('rejects methods that reference unknown validator ids', () => { expect(() => { - new RpcRegistry( - [ + new RpcRegistry({ + methods: [ { method: 'test:bad', permission: 'test:call', @@ -355,8 +353,7 @@ test('rejects methods that reference unknown validator ids', () => { handler: () => ({ ok: true }), }, ], - undefined, - testValidatorOptions, - ); + validators: testValidatorOptions, + }); }).toThrow("Unknown request validator 'missing:validator'"); }); diff --git a/test/sandboxing.test.ts b/test/sandboxing.test.ts index 6f10fc6..f9a9cae 100644 --- a/test/sandboxing.test.ts +++ b/test/sandboxing.test.ts @@ -71,8 +71,8 @@ function createSession(workerFactory: WorkerFactory, registry: RpcRegistry): Wor } function createRegistry(): RpcRegistry { - return new RpcRegistry( - [ + return new RpcRegistry({ + methods: [ { method: 'vault:read', permission: 'vault:read', @@ -97,9 +97,8 @@ function createRegistry(): RpcRegistry { }, }, ], - undefined, - testValidatorOptions, - ); + validators: testValidatorOptions, + }); } test('sandbox global documentation only advertises the intentional host surface', () => {