240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
}
|