/** * The browser twin of the tool-cordis context facade: a whitelist of * lifecycle-safe verbs plus optional `ctx.get()` lookup and declared-service * property access, with * framework internals withheld and Context-valued returns denied. Two seats * carry extra machinery: `slots`, where the register proxy assigns the * shadowing priority and ledgers the registration — invoking the service with * the traced receiver so the effect lands on the CALLING plugin's fiber * (SlotsService.register must stay a prototype method for exactly that * reason) — and `theme`, whose override source is pinned to the package id. * * This is API discipline, not a security boundary: a dynamic package's code is * as trusted as the host process that accepted its definition. */ import { Context } from '@deepseek-ai/cordis' import type { DynamicCordisPackage } from '@deepseek-ai/dsh-api-remotes/client' import type { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' /** Facade verbs beyond declared services (host CTX_VERBS twin). */ const CTX_VERBS = new Set([ 'effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce', ]) const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce']) /** One package's slot-registration ledger row (contribution projection source). */ export interface DynamicCordisSlotLedgerRow { /** Target slot name. */ slot: string /** The assigned shadowing priority (globally unique — how winners are matched back to packages). */ priority: number | undefined } /** What the facade needs beyond the real ctx to govern one package. */ export interface DynamicCordisGuardEnv { /** The dispatched Package row. */ pkg: DynamicCordisPackage /** Ledger sink: every slot registration this package makes. */ ledger: DynamicCordisSlotLedgerRow[] /** * Ownership index sink: the component object seated in a slot, so a later * render crash reported against the stored entry can be attributed back to * this package. Identity is the key — the registry stores the component * verbatim — which is why nothing else has to be remembered about the entry. * @param component - whatever the package passed as its component. */ claim(component: unknown): void /** Allocate one page-local shadowing rank; later registrations sort first. */ allocatePriority(): number /** Report one post-activation guard rejection to the owning Agent. */ reportFailure(error: Error): void } /** Reject any service return that is a cordis Context (host guard twin). */ function denyContext(value: unknown, service: string, env: DynamicCordisGuardEnv): unknown { if (value instanceof Context) { return rejectGuard(env, `service "${service}" returned a cordis Context, which the dynamic facade does not expose. ` + 'Operate through your own plugin ctx and the services you declared — never another context.', ) } return value } /** * Forward service methods with the traced service as receiver — `this.ctx` * inside prototype methods (slots.register) must stay the CALLER's ctx so * effects land on the calling plugin's fiber — while denying Context returns. */ function guardedService(service: object, name: string, env: DynamicCordisGuardEnv): unknown { return new Proxy(service, { get(target, prop) { const value = Reflect.get(target, prop, target) as unknown if (typeof value !== 'function') return denyContext(value, name, env) return (...args: unknown[]): unknown => { const result = Reflect.apply(value, target, args) as unknown if (result instanceof Promise) return result.then(resolved => denyContext(resolved, name, env)) return denyContext(result, name, env) } }, }) } /** Erased register options as this facade reads and rewrites them. */ interface ErasedSlotOptions { name?: string priority?: number [key: string]: unknown } /** * The slots seat: automatic shadowing priority and ledger recording around the * traced service's own register. */ function guardedSlots(slots: SlotsService, env: DynamicCordisGuardEnv): unknown { return new Proxy(slots, { get(target, prop) { const value = Reflect.get(target, prop, target) as unknown if (prop !== 'register') { if (typeof value !== 'function') return denyContext(value, 'slots', env) return (...args: unknown[]): unknown => denyContext(Reflect.apply(value, target, args), 'slots', env) } return (rawOptions: unknown, component: unknown): unknown => { if (typeof rawOptions !== 'object' || rawOptions === null) { return rejectGuard(env, 'slots.register(options, component) needs an options object with a `name`') } const options = { ...rawOptions as ErasedSlotOptions } const slot = options.name if (typeof slot !== 'string' || slot.length === 0) { return rejectGuard(env, 'slots.register options need a string `name` (the target slot key)') } if (slot === 'tool.view.cordis') { if (options.key !== 'self') { return rejectGuard(env, 'tool.view.cordis only accepts key "self"; the runtime binds it to this Package') } options.key = `${env.pkg.pluginId}.${env.pkg.packageId}` } // Shadowing kinds get a page-local rank. Later registrations sort first; // chain slots keep their own election (select order) untouched. const spec = (slots.spec as (key: string) => { kind?: string } | undefined)(slot) let priority = options.priority if (spec === undefined || spec.kind !== 'chain') { priority = env.allocatePriority() options.priority = priority } const register = Reflect.get(target, 'register', target) as unknown as (opts: object, comp: unknown) => () => void const dispose = register.call(target, options, component) env.ledger.push({ slot, priority }) // After the registry accepted it: a rejected registration seats no entry, // so claiming one would index a component no crash can ever name. env.claim(component) return dispose } }, }) } /** * The theme seat: `overrideTokens`' source is FORCED to the package id — a * dynamic package can never impersonate (or evict) another source's layer, and * its own layers converge under one identity unload can reason about. The * layer's disposer is additionally hung on the calling fiber, because the * documented contract is "unload restores" and model code cannot be trusted to * keep the returned handle (slots parity — register hangs its own cleanup). * Everything else forwards through the generic guard. */ function guardedTheme(theme: ThemeService, env: DynamicCordisGuardEnv, ctx: Context): unknown { return new Proxy(theme, { get(target, prop) { if (prop !== 'overrideTokens') { const value = Reflect.get(target, prop, target) as unknown if (typeof value !== 'function') return denyContext(value, 'theme', env) return (...args: unknown[]): unknown => { const result = Reflect.apply(value, target, args) as unknown if (result instanceof Promise) return result.then(resolved => denyContext(resolved, 'theme', env)) return denyContext(result, 'theme', env) } } return (source: unknown, tokens: unknown): unknown => { // Two-argument shape preserved so the facade matches the documented // service signature; the source VALUE is replaced, never trusted. if (tokens === undefined && typeof source === 'object' && source !== null) { return rejectGuard(env, 'theme.overrideTokens(source, tokens) takes two arguments; source is replaced with your package id, ' + 'so pass any string first and the token map second: overrideTokens(\'mine\', { \'--dsw-alias-…\': { light: \'…\', dark: \'…\' } })', ) } const method = Reflect.get(target, 'overrideTokens', target) const dispose = Reflect.apply(method, target, [`${env.pkg.pluginId}.${env.pkg.packageId}`, tokens]) as () => void // Fiber-owned lifetime; the returned handle stays valid for early // removal (the service disposer is idempotent per layer identity). ctx.effect(() => dispose, 'cordis-client-runner: dynamic theme override layer') return dispose } }, }) } /** * Build the facade one dynamic plugin's `apply` receives (host sandboxContext * twin, browser seats). `ctx.get(name)` performs optional lookup; direct * `ctx.serviceName` access is gated by the fiber's `inject` declaration. * @param ctx - the plugin's real fiber ctx (loader-created). * @param env - package row + ledger sink. * @returns the whitelisting proxy standing in for ctx. */ export function dynamicCordisContext(ctx: Context, env: DynamicCordisGuardEnv): Context { const declared = new Set(Object.keys(ctx.fiber.inject)) const denyRead = (prop: string): never => { if (ctx.get(prop) !== undefined) { return rejectGuard(env, `service "${prop}" is not declared by your plugin. Declare it on the plugin you return: ` + `{ inject: ['${prop}', …], apply(ctx) { … } } — a plain \`function\` has no declaration site, ` + 'so use the object form. The runtime then parks the package if the provider unloads.', ) } return rejectGuard(env, `dynamic ctx does not expose "${prop}". Available: ctx.on / ctx.provide / timer helpers after injecting timer, and any service your ` + 'returned plugin declared in inject (slots and theme are the usual UI seats). Framework internals are withheld ' + 'by design.', ) } const readService = (name: string, requireDeclaration: boolean): unknown => { if (requireDeclaration && !declared.has(name)) return denyRead(name) const service = denyContext(ctx.get(name), name, env) if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service if (name === 'slots') return guardedSlots(service as SlotsService, env) if (name === 'theme') return guardedTheme(service as ThemeService, env, ctx) return guardedService(service, name, env) } return new Proxy({}, { get(_target, prop) { if (prop === 'get') return (name: string): unknown => readService(name, false) if (typeof prop !== 'string') return undefined // Lazy verb forwarder (host twin): resolve ctx[verb] only when called. if (CTX_VERBS.has(prop)) { return (...args: unknown[]): unknown => { if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer') const method = ctx[prop as keyof Context] return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args) } } return readService(prop, true) }, set(_target, prop) { return rejectGuard(env, `dynamic ctx is read-only; cannot assign "${String(prop)}"`) }, has: (_target, prop) => prop === 'get' || (typeof prop === 'string' && ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))), }) as unknown as Context } function rejectGuard(env: DynamicCordisGuardEnv, message: string): never { const error = new Error(message) env.reportFailure(error) throw error }