Files
deepseek-harness/packages/core/scope
Tianyi Cui cb03c8c284 fix(scope): align trust and input boundaries
Rewrite the agent-scope RFC with executable examples and an explicit security non-goal. Harden subagent scalar and depth validation, and pin live tool-filter semantics across code, tests, and generated docs.
2026-07-12 11:17:57 +08:00
..

dsh-scope

Scoped-context registration primitive. createScope(ctx, key) mints a Cordis context that TAGS everything registered through it with an opaque ScopeKey and OWNS those registrations' lifetime (one backing fiber drives both facts); scopeOf(ctx) reads the tag; scopeTarget(base, key) builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the Agent object — the Agent.ctx contract in dsh-agent), but the mechanism is key-agnostic so packages below the agent layer (dsh-session, dsh-system-prompt) depend on it without a dependency cycle.

Public API

  • createScope(ctx: Context, key: ScopeKey): Scope Mint a scope under ctx's fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when ctx's fiber is disposing (INACTIVE_EFFECT).
  • Scope.ctx The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an extend, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
  • Scope.rawDispose The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
  • Scope.dispose(): Promise<void> Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when rawDispose invoked the underlying single-shot Cordis disposer first.
  • scopeOf(ctx: Context): ScopeKey | undefined The tag a context (or any context derived from it) carries; undefined = context-global.
  • scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T> Build the dispatch thisArg for a scope-filtered event: capture and compose base's own Context.filter with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; key === undefined ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to base, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to true, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit configurable: true descriptor, while an omitted or false flag is rejected before the base is touched. Listener this stays base-shaped. { global: true } listeners bypass filtering (Cordis semantics).
  • Scoped<T> The compile-time carrier brand: scope-filtered events demand it as their this type, so dispatching with a bare subject is a compile error.
  • isScopeCarrier(value) / carrierKeyOf(value) Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
  • scopeHost(ctx, services) Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared dispose() waits for both the host fiber and every minted scope, including a child already tearing down through rawDispose.

Design contract

Ownership and visibility derive from ONE fact — which context a registration went through. An explicit { scope } registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: the agent-scope RFC.

Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.