import type { Interpreter } from '@syuilo/aiscript' import { utils, values } from '@syuilo/aiscript' import type { Value, VFn } from '@syuilo/aiscript/interpreter/value.js' import { dispatchCapability } from '@/capabilities/dispatcher' import { listCapabilities, registerCapability, unregisterCapability, } from '@/capabilities/registry' import type { Capability, CapabilitySignature, PermissionKey, } from '@/capabilities/types' import type { Command, useCommandStore } from '@/commands/registry' import type { Principal } from '@/permissions/principal' import { PERMISSION_KEYS } from '@/permissions/schema' import { makeRegistrationId } from '@/plugins/registrationId' import { commands, unwrap } from '@/utils/tauriInvoke' import { useRepresentative } from '@/windows/roles' import { version as appVersion } from '../../package.json' import { type NoteDeckEventName, SUPPORTED_EVENT_NAMES, subscribeNoteDeckEvent, } from './events' export interface NoteDeckEnvContext { commandStore: ReturnType /** * この env で動くコードの principal (#712 §5.5)。プラグイン / ウィジェット / * Play / Page は `{ kind: 'plugin', pluginId }` (pluginId は必須 populate — * ウィジェットは `widget:`、Play は `play:` 等)。playground * (本人がその場で書いて実行するコード) は `{ kind: 'user' }`。 * populate できない実行文脈は plugin principal を名乗れない (型で強制)。 */ principal: Principal /** * 登録アイテムの名前空間になるプラグイン安定キー (#794 未決事項 2)。 * `pluginProviderKey` の戻り値 — MisStore 配布物は storeId、ローカル自作は * `local:`。全登録 ID がこの下に切られるので、プラグイン同士が * 名前で衝突しない。 */ provider: string /** Set after interpreter is created, enables Nd:register_command handlers */ interpreter?: Interpreter /** * この env が登録した capability を dispatcher 経由で実行中の呼び出し元 * (#1099)。`Nd:register_command` の handler が走る間だけ積まれ、handler 内の * `Nd:call` / `Nd:http` / `Mk:api` は「呼び出し元 ∩ この env の principal」で * 判定される。複数の呼び出しが同時に走ると全員の AND になる (安全側に倒す)。 */ callers: Principal[] /** * この env が行った全登録の解除関数 (#794 原則 5)。 * * 「プラグインからの登録は必ずコンテキスト経由で行い、停止時に一括解除される」 * を単一の配列で表現する。登録面ごとに配列を増やす方式だと、開放面が 1 つ * 増えるたび cleanup 側にも 1 行足す必要があり、#794 が解こうとしている * まだら化を解除側で再発させる。 */ disposers: Array<() => void> /** * 呼び出し文脈のアカウントを返すアクセサ (#821)。プラグインは * withPluginAccountContext が設定するミュータブル文脈を読むため関数。 * widget / Play / Page はホスト元アカウント固定のクロージャ。 * 未指定 = アカウント文脈なし (Nd:call は accountId を渡さない)。 */ getAccountId?: () => string | null /** * アプリ側から入る handler の実行 (Nd:on のイベント / コマンドパレット / * dispatcher 経由の capability) をアカウント文脈で包む (#1306)。これらは * 別の handler が Mk:api を await している最中にも発火するので、包まずに * 走らせるとその handler のアカウントを拾う。プラグインは * withPluginAccountContext (直列化 + スロット設定) を渡し、渡された * accountId (無ければ null = fail-closed) でだけ走らせる。未指定なら * そのまま走る (widget / Play / Page はアカウント固定のクロージャで、 * 文脈が実行の合間に変わることが無い)。 */ runWithAccount?: ( accountId: string | null, fn: () => Promise, origin?: 'ui' | 'other', ) => Promise /** * 動かしている本文の hash (#1293、プラグインだけ)。起動時に Rust が計算し、 * `http.fetch` の実行要求に添える (loopback 同意の「動かしている本文 = * ディスクの本文」の検査) */ getBodyHash?: () => string | null /** * 今走っている実行の起点 (#1293、プラグインだけ)。`'ui'` = 人が押した入口の中。 * loopback の同意ダイアログを出すかを決めるだけで、認可の境界ではない */ getOrigin?: () => 'ui' | 'other' } /** * 呼び出し元の連鎖にこの env 自身が居るか (#1306)。handler の中から Nd:call で * 自分の capability を呼んだ形で、その実行は今走っている handler の一部なので * runWithAccount に乗せると自分の完了を待って止まる。plugin 以外の principal は * runWithAccount を渡さないので比較は plugin だけでよい */ function isSelfCall(chain: readonly Principal[], self: Principal): boolean { return ( self.kind === 'plugin' && chain.some((p) => p.kind === 'plugin' && p.pluginId === self.pluginId) ) } export function createNoteDeckEnv( ctx: NoteDeckEnvContext, ): Record { const { commandStore } = ctx const consts: Record = {} const runWithAccount = ctx.runWithAccount ?? ((_accountId: string | null, fn: () => Promise) => fn()) /** plugin の dispatch に添える起点と本文の hash (#1293) */ const loopbackContext = () => { const bodyHash = ctx.getBodyHash?.() return { origin: ctx.getOrigin?.() ?? ('other' as const), ...(bodyHash ? { pluginBodyHash: bodyHash } : {}), } } // --- Feature detection --- consts.NOTEDECK = values.TRUE consts['Nd:version'] = values.STR(appVersion) // --- Nd:call --- // capability registry に登録されている任意の capability を呼び出す。 // permissions / requiresConfirmation は dispatcher が処理するため、 // ここでは結果の包み替えとエラー throw のみ行う。 // // principal は env 構築時に確定した ctx.principal (#712 §3.5)。以前は // 「plugin コードからの Nd:call は起動経路に関係なく plugin プロファイル // 単独で resolve する」を意図的な決定としていたが、AI (readonly) が plugin // 登録 capability 経由で plugin (safe + network.external) の権限を使える // 置換になっていたため不採用に改めた (#1099)。実行中の呼び出し元 // (ctx.callers) を onBehalfOf として渡し、dispatcher が AND で判定する。 consts['Nd:call'] = values.FN_NATIVE(async ([idVal, paramsVal]) => { utils.assertString(idVal) const params = paramsVal?.type === 'obj' ? (utils.valToJs(paramsVal) as Record) : undefined // 呼び出し元は同期的に写し取る — await の間に別の handler が積み下ろし // してもこの dispatch の判定は変わらない const onBehalfOf = [...ctx.callers] const result = await dispatchCapability(idVal.value, params, { principal: ctx.principal, accountId: ctx.getAccountId?.() ?? null, ...(onBehalfOf.length > 0 ? { onBehalfOf } : {}), }) if (!result.ok) { // 確認ダイアログのキャンセルはユーザーの正常な操作 (#1074)。AiScript に // try/catch は無く、throw するとプラグイン側で握り潰せないため、本家 // Mk:api の失敗時と同じく error 値で返す (Core:type(r) == "error") if (result.code === 'user_cancelled') { return values.ERROR('user_cancelled', values.STR(result.error)) } throw new Error( `Nd:call ${idVal.value} (${result.code}): ${result.error}`, ) } return utils.jsToVal(result.result) }) // --- Nd:capabilities --- // registry にある capability の宣言情報を配列で返す。プラグインが // 「使える capability の一覧」を自己発見できるため、Nd:* のドキュメントを // 別管理する必要がない。 consts['Nd:capabilities'] = values.FN_NATIVE(() => { return utils.jsToVal( listCapabilities().map((c) => ({ id: c.id, label: c.label, description: c.signature?.description ?? '', params: c.signature?.params ?? {}, returns: c.signature?.returns ?? null, permissions: c.permissions ?? [], // 関数形 (確認内容を組み立てる) も「確認あり」。HTTP の一覧と同じ判定にする requiresConfirmation: !!c.requiresConfirmation, })), ) }) // --- Nd:http --- // 外部 HTTP API (CORS なし) を叩く。Rust 側で SSRF 防御 / size limit / // timeout を通す。通信断 (DNS 失敗 / 接続拒否 / タイムアウト) は Rust 側が // `status: 0` + `error` の値で返す (#1294) — AiScript には try/catch が // 無いので throw ではウィジェットが「通信できません」を描けない。throw が // 残るのは権限拒否 / 確認のキャンセル / 要求の形の誤りだけ // // #712 §5.5 / #711: plugin 文脈では `http.fetch` capability (= dispatcher) // への薄い alias — plugin プロファイルの `network.external` gate + 確認 // ダイアログ + Spotlight が効く。従来の直 invoke はプラグイン設定値に // 入れた secret の無 gate 送出経路 (exfiltration) だった。 // user 文脈 (playground) は本人の操作なので従来どおり直接 invoke (挙動不変)。 // AiScript 側の引数 / 戻り値の形は両経路で同一。 consts['Nd:http'] = values.FN_NATIVE(async ([urlVal, optionsVal]) => { utils.assertString(urlVal) const options = optionsVal?.type === 'obj' ? (utils.valToJs(optionsVal) as Record) : {} if (ctx.principal.kind === 'user') { const request = { url: urlVal.value, method: typeof options.method === 'string' ? options.method : null, headers: isStringRecord(options.headers) ? options.headers : null, body: typeof options.body === 'string' ? options.body : null, timeoutMs: typeof options.timeoutMs === 'number' ? options.timeoutMs : null, } const response = unwrap(await commands.httpFetch(request)) return utils.jsToVal({ status: response.status, headers: response.headers, body: response.body, ...(response.error != null ? { error: response.error } : {}), }) } const params: Record = { url: urlVal.value } if (typeof options.method === 'string') params.method = options.method if (isStringRecord(options.headers)) params.headers = options.headers if (typeof options.body === 'string') params.body = options.body if (typeof options.timeoutMs === 'number') { params.timeoutMs = options.timeoutMs } const onBehalfOf = [...ctx.callers] const result = await dispatchCapability('http.fetch', params, { principal: ctx.principal, ...(onBehalfOf.length > 0 ? { onBehalfOf } : {}), ...loopbackContext(), }) if (!result.ok) { throw new Error(`Nd:http (${result.code}): ${result.error}`) } const response = result.result as { status: number headers: Record body: string error?: string } return utils.jsToVal({ status: response.status, headers: response.headers, body: response.body, ...(response.error != null ? { error: response.error } : {}), }) }) // --- Nd:on --- // NoteDeck 内部イベントの購読。AiScript ハンドラに整形済 payload を渡す。 // 戻り値は AiScript の関数で、呼ぶと unsubscribe される。 // プラグイン終了時には cleanupNoteDeckEnv が全 subscription を一括解除する // ので、ユーザーは unsubscribe を明示的に呼ばなくても安全。 consts['Nd:on'] = values.FN_NATIVE(([nameVal, handlerVal]) => { utils.assertString(nameVal) utils.assertFunction(handlerVal) const eventName = nameVal.value if (!isSupportedEvent(eventName)) { throw new Error( `Nd:on: unsupported event "${eventName}". ` + `Supported: ${SUPPORTED_EVENT_NAMES.join(', ')}`, ) } const handler = handlerVal as VFn let active = true const unsubscribe = subscribeNoteDeckEvent(eventName, (payload) => { const interp = ctx.interpreter if (!interp) return // 無人の副作用の起点は代表窓だけ (#1131 §7)。登録は受けるが、ミラー / // ポップアウトでは配送しない (プラグインはデッキ画面を描く窓ごとに動くので、 // 配送すると窓の数だけ走る) if (!useRepresentative().isRepresentative.value) return // payload にアカウントがあるイベント (カラム / ストリーミング / ノート / // 通知) はそのアカウント、無いイベント (メモ / skill / テーマ) は null const accountId = typeof payload.accountId === 'string' ? payload.accountId : null runWithAccount(accountId, async () => { // キューで待っている間に解除された購読は走らせない (解除は以後の配送を // 止めるだけで、積み済みの 1 件は取り消せない) if (!active) return await interp.execFn(handler, [utils.jsToVal(payload)]) }).catch((e) => { console.warn('[Nd:on]', eventName, e) }) }) const stop = (): void => { active = false unsubscribe() } ctx.disposers.push(stop) // AiScript 側に返す unsubscribe 関数 return values.FN_NATIVE(() => { stop() const idx = ctx.disposers.indexOf(stop) if (idx >= 0) ctx.disposers.splice(idx, 1) }) }) // --- Nd:register_command --- // 5 引数目の `options` を渡すと capability registry にもミラー登録され、 // AI tool calling / HTTP API / CLI からも呼べるようになる。 // options なし = 従来通り UI コマンドパレット専用。 // // カラム操作 (旧 Nd:columns / Nd:addColumn / Nd:removeColumn) は // `Nd:call('column.list')` / `Nd:call('column.add', ...)` / // `Nd:call('column.remove', ...)` で代替する (capability registry 経由)。 consts['Nd:register_command'] = values.FN_NATIVE( ([idVal, labelVal, iconVal, handlerVal, optionsVal]) => { utils.assertString(idVal) utils.assertString(labelVal) utils.assertString(iconVal) utils.assertFunction(handlerVal) const parsed = parseRegisterCommandOptions(optionsVal) // 旧実装は全プラグイン共通の `nd-plugin:` 名前空間だったため、別々の // プラグインが同じ id を使うと無言で後勝ち上書きされていた const commandId = makeRegistrationId(ctx.provider, idVal.value) if (commandStore.commands.has(commandId)) { throw new Error( `Nd:register_command: "${commandId}" already registered`, ) } const handler = handlerVal as VFn // 1. パレットの入口 (本人操作): 戻り値不要なので fire-and-forget const command: Command = { id: commandId, label: labelVal.value, icon: iconVal.value, category: 'general', // キーバインドは keybinds.json5 の上書き (設定 GUI の「プラグイン」節 / AI の // keybinds.set) で付け、読む側が resolveShortcuts で引く (#1243) shortcuts: [], execute: () => { const interp = ctx.interpreter if (!interp) { console.warn('[Nd:register_command] interpreter not available') return } // パレットにはアカウントの文脈が無い (#941)。null で包むのは、別の // handler の実行中に押されてもそのアカウントを拾わないため (#1306) runWithAccount(null, () => interp.execFn(handler, [])).catch((e) => { console.warn('[Nd:register_command]', e) }) }, } commandStore.register(command) ctx.disposers.push(() => commandStore.unregister(commandId)) // 2. options で aiTool と signature を宣言したものだけ capability にもなる // (AI tool / HTTP API / 他プラグインの Nd:call から呼べる)。Command と // capability は別の登録で、片方だけ消えることは無い (disposers が両方外す) if (!(parsed.aiTool && parsed.signature)) return const capability: Capability = { id: commandId, label: labelVal.value, icon: iconVal.value, category: 'general', aiTool: true, signature: parsed.signature, execute: async (params, capCtx) => { const interp = ctx.interpreter if (!interp) { console.warn('[Nd:register_command] interpreter not available') return } const caller = capCtx?.principal const accountId = capCtx?.accountId ?? null if (!caller) { runWithAccount(accountId, () => interp.execFn(handler, [])).catch( (e) => { console.warn('[Nd:register_command]', e) }, ) return } // dispatcher 経由 (AI tool / HTTP API / 他プラグイン): 呼び出し元を // 積んで handler の完了を待つ。handler 内の Nd:call / Nd:http / Mk:api // は「呼び出し元 ∩ plugin」で判定される (#1099)。user はプロファイルを // 持たないので積まない。同期実行では async native (Nd:call 等) を // 呼べないので execFnSimple で待つ const args = params && Object.keys(params).length > 0 ? [utils.jsToVal(params)] : [] // 呼び出し元の連鎖ごと積む (AI → 別 plugin の command → この command // のように多段でも、上流全員の AND になる)。user は積まない const chain = [...(capCtx.onBehalfOf ?? []), caller] const tracked = chain.filter((p) => p.kind !== 'user') // 呼び出し元の積み下ろしは実際に走るときに行う (キューで待つ間に // 他の実行の Nd:call が写し取る連鎖へ混ざらないように) const run = async () => { ctx.callers.push(...tracked) try { const result = await interp.execFnSimple(handler, args) return utils.valToJs(result) } catch (e) { console.warn('[Nd:register_command]', e) throw e } finally { for (const p of tracked) { const idx = ctx.callers.lastIndexOf(p) if (idx >= 0) ctx.callers.splice(idx, 1) } } } // 自分の handler の中からの Nd:call はその handler の実行の一部。 // 文脈はすでにその handler のもので、キューに乗せると自分を待って // 止まるので、そのまま走らせる (#1306) // 起点 (#1293): 本人がパレットから押した (caller が user) なら 'ui'、 // AI / 外部トークン / 別のプラグイン経由は 'other' const origin = caller.kind === 'user' ? 'ui' : 'other' return isSelfCall(chain, ctx.principal) ? run() : runWithAccount(accountId, run, origin) }, } if (parsed.permissions) capability.permissions = parsed.permissions if (parsed.requiresConfirmation !== undefined) { capability.requiresConfirmation = parsed.requiresConfirmation } registerCapability(capability) ctx.disposers.push(() => unregisterCapability(commandId)) }, ) return consts } /** * Cleanup NoteDeck API resources (unregister commands, unsubscribe events). * プラグイン abort / カラム破棄 / interpreter 再起動時に呼ばれる。 */ export function cleanupNoteDeckEnv(ctx: NoteDeckEnvContext): void { // 配列を先に空にしてから回す。解除中に例外が出ても未解除分が残らないよう、 // また二重 cleanup で同じ disposer が二度走らないようにするため。 const disposers = ctx.disposers.splice(0, ctx.disposers.length) for (const dispose of disposers) { try { dispose() } catch (e) { // 1 つの失敗で残りの解除を止めない (解除漏れの方が被害が大きい) console.warn('[cleanupNoteDeckEnv] dispose failed:', e) } } } function isSupportedEvent(name: string): name is NoteDeckEventName { return (SUPPORTED_EVENT_NAMES as readonly string[]).includes(name) } function isStringRecord(v: unknown): v is Record { if (!v || typeof v !== 'object' || Array.isArray(v)) return false for (const value of Object.values(v as Record)) { if (typeof value !== 'string') return false } return true } interface ParsedRegisterCommandOptions { aiTool: boolean permissions?: PermissionKey[] requiresConfirmation?: boolean signature?: CapabilitySignature } /** * `Nd:register_command` の 5 引数目を解析する。AiScript の obj 値から * Command 用フィールドを取り出す。未知フィールドは無視する。 */ function parseRegisterCommandOptions( optionsVal: Value | undefined, ): ParsedRegisterCommandOptions { const out: ParsedRegisterCommandOptions = { aiTool: false } if (optionsVal?.type !== 'obj') return out const options = utils.valToJs(optionsVal) as Record if (options.aiTool === true) out.aiTool = true if (Array.isArray(options.permissions)) { // 自己申告の permission は語彙に無いキーを弾く (#1098)。未知キーは // checkPermissions で常に拒否になるため穴ではないが、黙って dispatch // 不能なコマンドが登録されるより登録時に落とす方が作者に伝わる const permissions: PermissionKey[] = [] for (const p of options.permissions) { if (typeof p !== 'string') continue if (!(PERMISSION_KEYS as readonly string[]).includes(p)) { throw new Error(`Nd:register_command: unknown permission key "${p}"`) } permissions.push(p as PermissionKey) } out.permissions = permissions } if (typeof options.requiresConfirmation === 'boolean') { out.requiresConfirmation = options.requiresConfirmation } if ( options.signature && typeof options.signature === 'object' && !Array.isArray(options.signature) ) { const sig = options.signature as Record const signature: CapabilitySignature = { description: typeof sig.description === 'string' ? sig.description : '', } if (sig.params && typeof sig.params === 'object') { signature.params = sig.params as CapabilitySignature['params'] } if (sig.returns && typeof sig.returns === 'object') { signature.returns = sig.returns as CapabilitySignature['returns'] } if (typeof sig.cheap === 'boolean') signature.cheap = sig.cheap out.signature = signature } return out }