Master brought 50 commits (the tool-cordis group, dsh-code-runtime + worker,
the tools/execute around-dispatch seam + timeout-policy, repeat-tool-guard,
agent/session-prefix, the ui reorganization). Beyond the ten textual
conflicts, the merge reconciles master's new seams with this branch's
scoped-registration world:
- tools/execute (new waterfall around core dispatch): dispatched with the
SAME exec.agent carrier as the pre/post waterfalls — an agent.ctx wrapper
times/retries only its own agent's calls — and its base thunk resolves the
tool through the caller's visible view (get(exec.name, exec.agent)), so a
scoped/shadowed tool dispatches and a restricted-away global stays
UNKNOWN_TOOL. Declared this: Scoped<ToolRegistry> with the scope-filtered
doc sentence; invariants table + verify-scoped-dispatch pin it (21 events).
- agent/session-prefix (new waterfall, once per loop instance): composed via
the fused agentEvents dispatcher (scope-filtered like every agent-subject
event), declared this: Scoped<Agent>, table-pinned. agent/pre-step keeps
master's new sessionPrefix parameter with this branch's Scoped this.
- timeout-policy reads the budget through the caller's visible view
(get(exec.name, exec.agent)): a scoped tool's own timeoutMs governs its
calls; a global name-twin's budget is never misapplied to a shadowing
per-agent variant.
- tool-cordis: cordis_inspect's tools section lists the CALLING agent's view
(its description promises "what you can call"); the sandbox tool façade's
reads resolve through the mount's own scope, mirroring where its register
lands writes; sandboxRegisterTool's return type carries the exact-disposer
union honestly. dsh-scope declared as peer+dev with the project reference.
- doc-sync chain unions master's verify-cordis-api with this branch's
verify-scoped-dispatch; the generated catalogs, event matrix (the
zero-dispatcher guard passes over master's new events), module graph, and
the cordis api-catalog are regenerated on the merged surface.
Full gate sequence green on the merged tree: typecheck, lint, per-file 100%
coverage (2668 tests), snapshots (38), doc-sync, module graph, build,
hygiene, demo smoke.
The sandbox execute wrapper JSON round-tripped the return and blindly cast it
to ToolExecuteReturn. A JSON-valid but wrong-shape return — a bare string,
{ content: 'ok' }, blocks without a type tag — sailed through: the registry
spreads result.content, so { content: 'ok' } became ['o','k'], passed the
session log's isJsonValue gate, and the DeepSeek serializer then flattened it
to '(no output)' — silent corruption of the next model request and every
replay, instead of a contained tool error.
The round-tripped value is now shape-checked against the two ToolExecuteReturn
forms (array of content blocks, or { content: blocks, meta? }); block checks
are structural only (plain object + string type tag) because the ContentBlock
union is merge-extensible. A wrong shape — and the formerly cryptic
forgot-return/bare-string cases — fails that one call with a teaching error
echoing a truncated preview of what was returned and the two valid forms.
New specs pin the object-form pass-through (meta included), six rejection
shapes, and the preview truncation; per-file 100% coverage holds.
cordis provide() accepts any value and cross-mount composition advertises
ctx.provide('name', value), but the façade's readService unconditionally
proxied every declared service — new Proxy('42') throws "Cannot create proxy
with a non-object as target or handler", so a consumer of a primitive-valued
service crashed on first read with an error naming neither the service nor
the fix. A primitive or null value now passes through unwrapped (after the
denyContext check); only object- and function-valued services are proxied —
a primitive has no method that could hand back a Context, so nothing is lost.
New cross-mount spec pins both read paths (ctx.<name> and ctx.get) for a
number and a null provided value.
The sandbox docs overclaimed a containment contract the design never makes:
"capability access is routed through cordis services, never Node built-ins,
so everything a mounted plugin does stays inspectable and disposable". The
host-realm helpers on the sandbox global (harness, console, btoa) are
reachable functions, so mount code that goes looking can reach the host realm
through one of them — accepted under the trust stance, because the ctx a
mount ultimately receives is fully privileged anyway. Reword the sandbox
module doc, the README trust stance, and the RFC sandbox-semantics section to
say exactly that: the traps and small global surface STEER honest code onto
the cordis services; they are not a security boundary.
Reviewer-agent C1 on the audit delta: "byte-identical" overstated the
untampered-path guarantee — a 190-order section registered AFTER the
structured runtime sorts before the instruction in the registry's stable
sort but after it in the re-assert's band insertion. Intra-band section
order carries no contract, so the behavior is right and unchanged; the
module doc and both in-code comments now say exactly that instead of
claiming byte identity.
Codex follow-up findings on the carrier commit, both verified:
B: the invariant guard only protected the bind path — an overlay key
colliding with a non-configurable, non-writable OWN prop of a (pathological)
base would have the get trap report the overlay value, which the engine
rejects as a proxy invariant violation (TypeError at read time). The pin
check now runs FIRST and covers both invariant-pinned shapes (non-writable
own data prop reported as-is; getterless non-configurable accessor reported
undefined via the delegated read) before overlay and bind alike. Such a base
forgoes scope filtering by construction — correctness of the read beats
filtering for a base no production code ships.
C: `constructor` is looked up for identity, never invoked as a subject
method — binding it broke `carrier.constructor === Subject` for no benefit;
it now returns raw (the same special-case withProps had). Both pinned in
scope.spec: the frozen-own-filter collision yields the base's value without
throwing, and class identity survives the carrier.
ds-review-bot delta-round finding, verified: cordis hands the carrier to
listeners as `this`, and the event declarations type it Scoped<Agent> — so
driving the subject through it (this.send(...) in an agent/* listener) is a
SUPPORTED shape. The withProps-based carrier delegated gets with the PROXY
as receiver, so ReactLoopAgent's send/steer/cancel — which read the
native-private #carrier through a getter — threw TypeError when called that
way (private members do not exist on proxy receivers).
scopeTarget now builds its own proxy: overlay props (the composed filter and
the carrier mark) answer from a null-shadowed literal via hasOwn (`in` would
let Object.prototype's toString/constructor shadow the subject's), every
other get delegates with the BASE as receiver (getters see the real object)
and returns functions bound to the base (method calls execute on the real
receiver), sets land on the base. A proxy-invariant guard reports frozen own
function props unchanged (binding them would violate the get invariant).
This kills the class at the seam — any subject with native privates works,
today's agents and whatever carries them next — instead of patching the one
#carrier field.
Pinned both ways: a scope.spec matrix (native-#private method/getter through
the carrier mutates the real object; set delegation; frozen-own-prop
invariant; overlay non-shadowing) and the bot's exact end-to-end scenario
(an agent/session-start listener calling this.send drives a real turn) —
both fail with TypeError against the withProps carrier.
Two review findings (#220) on the sandbox context façade:
- Undeclared services were reachable: the façade resolved any live global via
ctx.get(name), so ctx.bash worked without inject: ['bash']. A cross-mount
consumer could then depend on a provider cordis never saw — unmounting the
provider would neither park the consumer nor unwind its registered tools,
leaving a model-visible tool that fails only at execution. The façade now
reads ctx.fiber.inject and refuses any service the mount did not declare
(with a teaching error naming the inject fix), so the dependency is always
visible to cordis and its activation/unload semantics bind.
- ctx.tools.get returned the live ToolDefinition, including execute — mount
code could call another tool directly and bypass ToolRegistry.execute and
its pre/post-execute hooks and accounting. get now returns the same
read-only name/description/parameters view as schemas(), never an invocable.
Adds inject-gate and schema-view regression cases to sandbox-context.spec.ts
(undeclared property/get denied, declared allowed, the cross-mount zombie-tool
scenario refused at call time, get exposes no execute). Package stays at
per-file 100% coverage. RFC, mount description, and tool-catalog updated.
Review finding (#220): the guarded proxy only special-cased ctx.tools, so
mount code could reach an UNGUARDED context through ctx.root, ctx.extend(), or
a service instance's .ctx, then ctx.root.tools.register({…}) to bypass the
marker check and host-realm normalization — a raw vm-realm result would later
error a real agent turn at the session-log plainness check.
The sandbox ctx is now a whitelist façade, not a pass-through proxy: it exposes
only what a mount needs — tools.register (marker-guarded), on/once, provide, the
timer helpers, and injected services resolved through a guarded get — and denies
every framework-plumbing member (root, parent, fiber, reflect, registry, extend,
isolate, intercept, plugin, set, mixin, …) with a teaching error. Injected
services are wrapped so a method returning a Context is rejected on the way back
(the .ctx escape), closing the one indirect leak. There is no context-valued
member left to reach; cross-mount provide/inject is untouched (the plugin's own
inject and the fiber's pending/active gating are unchanged). ctx.plugin (child
plugins) and ctx.set are denied by design; ctx.effect is deferred (FIXME).
Adds tests/sandbox-context.spec.ts covering the escape class (root/extend/fiber/
plugin/set/… denied, the classic root.tools.register bypass, the .ctx escape,
read-only writes) plus the async-service and symbol/in-operator paths for 100%
coverage. RFC/README/tool-catalog/config-catalog updated; api-catalog.ts
regenerated (also picks up the codeRuntime service that entered on the master
merge and was left stale).
The tree SHAPE was the wrong surface for the model: what it needs from
cordis_inspect is what services, plugins, and capabilities are loaded, not the
fiber hierarchy. The plugins section is now a flat name + lifecycle-state list
from ctx.registry (deterministically sorted, one line per instance); the ASCII
tree renderer, the parent→child rebuild, and the dyn-id tree annotation are
deleted — dynamic mounts keep their own richer dynamic section (id, state,
provides, waits). Net -49 lines; RFC and READMEs state the flat-list contract.
Field sessions showed models writing tool schemas in the JSON-Schema dialect
by strong prior — type: 'integer', required: false, then the full
{ type:'object', properties, required: [...] } wrapper — and the rejection
text itself pushed a nearly-correct DSL attempt BACK to raw JSON Schema: one
stats tool cost three consecutive schema errors before mounting. The boundary
now normalizes wherever the input has exactly one meaning (wrapper unwrapped
with the required array becoming per-property flags at any nesting level,
integer → number, required: false → optional, all rebuilt as fresh host-realm
objects) and rejects only genuinely meaningless input, enumerating the valid
vocabulary in the error. Re-running the failing session mounts first-try.
The mount description documents both accepted forms.
The sandbox deliberately provides no Node API, and now says so instead of
letting a bare ReferenceError teach nothing: require, the timers, and fetch
are callable traps whose error redirects to the cordis alternative (inject:
['fs'] + ctx.fs, ['web'] + ctx.web, ['bash'] + ctx.bash, ['timer'] +
ctx.setTimeout — a fiber effect, unwound on unmount). Only function-shaped
globals are trapped; process/Buffer stay undefined so typeof feature probes
stay inert. The mount description and the demo persona state the routing rule,
and the demo mounts ctx.fs (local provider) and ctx.web (seam + keyless local
fetch provider) so agent-built plugins have real capabilities to build on.
Live-validated: a model that reached for Node setTimeout self-corrected to
inject: ['timer'] in one step and built a working ctx.web fetch tool.
Everything outside the package and example that a new top-level group and a
new demo touch: GROUP_ORDER in gen-module-graph and gen-doc-graphs (plus the
tools-service consumers list, the APP_EXAMPLES entry, and the graph-atlas
label/mode rows), the knip e2e entries, the packages/README group row, the
AGENTS.md layout and demo lines, and the regenerated module-graph /
config-catalog / graph-atlas / capability-seams / composition artifacts.
AGENTS.md and examples/AGENTS.md word-budget ceilings rise to current+5%
(1802 / 653): the new group and demo rows are genuine additions to both docs,
not condensable restatements.
Emits packages/cordis/tool-cordis/src/api-catalog.ts (the data cordis_inspect
serves the model) from the same JSDoc-enforcing AST walk as docs/cordis-catalog
(collectServices/collectEvents, plus the now-exported INHERITED_SERVICES
table): service summaries + method signatures, event modes + signatures, and
the transitive closure of type shapes the signatures reference — so a mounted
plugin reads that a bash run's stdout is { text, truncated } instead of
guessing. verify-cordis-api joins doc-sync as the freshness gate.
The coding spine (DeepSeek V4 + local bash on dsh-stdio-agent) plus
@deepseek-ai/dsh-tool-cordis loaded by package name, run via demo:cordis.
Ships the keyless Loader smoke (the export-shape / package-name-resolution
guard) and the with-key smoke: a real model mounts a listener whose tagged
console line actually fires, builds and calls its own reverse_text tool, and
composes two mounts via provide/inject — all world-verified against the
registry and session events.
New top-level packages/cordis/ group with the self-referential toolset:
cordis_inspect (services / plugin tree / tools / dynamic mounts / api / events,
the api section intersecting the generated catalog with the live service store),
cordis_mount (model-written code evaluated in a node:vm sandbox, mounted under
one cordis-dynamic group fiber as dyn-<n>), cordis_unmount (awaited disposal to
quiescence). Boundary mechanisms: dual-realm instanceof, JSON realm
normalization of dynamic tool results, marker-guarded registration, SchemaSpec
teaching errors, parse failures surfaced with the offending line + caret and a
line-scoped TypeScript hint, and the unmount-first recipe on tool-name
collisions. Config: vmTimeoutMs (schemastery, default 5000). Design record:
docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md.
The tool-catalog boot manifest, its regenerated output, and the pinned
tool-name list land here rather than with the other repo registration: the
completeness guard globs packages/*/tool-* and fails the generator (and the
core/tools spec) the moment the package directory exists.
The design record for tool-cordis: the three-tool contract, the vm sandbox
trust stance and boundary mechanisms, the dynamic-group lifecycle, cross-mount
provide/inject composition, the generated runtime API catalog, and the
alternatives weighed (per-capability registration tools, hand-maintained API
tables, a mount provenance event, a hardened sandbox).
Move compact-basic's private _extractText/_blocksToText into the interface
package as renderTranscript/renderContentBlocks, so the summarize path and a
future recall read path render one span identically. Byte-identical output
vs the private helpers it replaces; compact-basic delegates.
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.
Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
The chained-fused-dispatch spelling fix (0dc2fe90) was whack-a-mole: each
unrecognized dispatch spelling silently drops a producer edge from the
generated matrix, and only a human reading the table catches it. Convert the
class to a build failure: the generator now hard-errors when any DECLARED
event ends with zero dispatchers — dead vocabulary or a missed spelling,
both actionable ('teach the scan or add a DYNAMIC_EVENT_DISPATCHERS
override'). Zero LISTENERS stays legal: seven current rows (agent/request,
system-prompt/assemble, tools/change, ...) are ordinary extension points
dispatched for out-of-repo plugins.
The guard caught a real one on its first run: subagent/provider-removed
routes through the same contained events.dispatch as subagent/start|end
(it fires inside the provider registration's disposer), but the
DYNAMIC_EVENT_DISPATCHERS override list never got an entry when that
containment routing was introduced — the committed matrix (on master too)
claimed the event has NO dispatcher while tool-subagent listens for it.
Override added; matrix regenerated with the producer edge restored.
The partial-toolFilter materialization fix (da6c6d58) stopped one field
short: the adjacent agentOptions key in the SAME Config has the same
schemastery trap. An omitted agentOptions materializes {}, which is truthy —
so every yml-configured load put a dishonest agentOptions: {} on every start
request and the presence check in execute() could never be false through
config (only unit tests bypassing schemastery ever exercised that branch).
Harmless downstream today (the driver only spreads it), but the request
shape lied and the check was production-dead.
Same discipline as its toolFilter sibling: the omitted key now defaults to
undefined, the presence check is spelled !== undefined like its neighbors,
and a regression test (fails against the unfixed schema) pins that an
omitted agentOptions stays absent from the request. Swept every other
Config in the repo for the class: no further instances — omitted primitives
inside a materialized object stay ABSENT (verified empirically), so
subagent-mock's capabilities spread is safe, and the remaining object/array
fields all carry explicit defaults or the forced-undefined discipline
already.
Supersedes the single-slot staging the execution-identity fix (06c5f17e)
kept: the one pending slot needed a mismatch-drop branch plus a defensive
coverage-ignored finally to manage orphans, and it carried a latent trap —
under the loop's documented parallel-execution TODO, two in-flight capture
trips would overwrite the slot and BOTH be dropped.
Staging in a WeakMap<ToolExecution, {value}> makes the stale-stage class
structurally impossible instead of managed: an entry orphaned by an outer
short-circuiting listener can never match a different execution's lookup
(whatever call id that execution carries), needs no drop bookkeeping (the
map reclaims it with the execution object), and staging cannot cross-clobber
under parallel execution. Staging is the only layer this future-proofs — a
parallel cut would still owe its own single-accept rule for the captured
value, which is documented rather than claimed. Behavior is pinned by the
existing orphan/call-id-reuse regression tests, which pass unchanged; the
commit listener loses two branches and the v8-ignore.
Re-auditing the review-fix commits surfaced a regression the REPLACE
re-assert (825cbab3) introduced: unconditionally rebuilding both arrays as
filter(...)+append moved structured_output to the END of the model-visible
tool list on every untampered assembly (overriding the registry's
toolOrder/lexicographic contract) and moved the instruction section to the
absolute array end — renderPrompt reads ARRAY order, so any section above
order 190 would render before the trailing instruction, violating the
sections-sorted-ascending contract. The presence-check version it replaced
touched neither array when the entries were intact.
The re-assert keeps its REPLACE content semantics but is now
placement-preserving: the tool is replaced IN PLACE (duplicates collapse,
append only when stripped); the section is re-inserted at its
ascending-order position (the first entry above 190 — exactly where the
registry's stable sort put it, so the untampered path reaches the model
byte-identical). Pinned by two regression tests that fail against the
filter+append form: untampered placement (tool before a lexicographically
later tool, instruction before an order-200 section) and tamper recovery
(stripped section re-enters its band; an added duplicate collapses to one
right-schema entry).
ds-review-bot round-3 finding: agentEvents(ctx, agent).emit(...) has a
call-expression receiver the generator's identifier check missed, silently
dropping agent-loop as agent/session-start's producer. The generator now
recognizes a call receiver whose callee is agentEvents; graph regenerated
with the producer edge restored.
ds-review-bot round-2 findings: (1) dsh-subagent's runtime import of
@deepseek-ai/dsh-scope was undeclared in its manifest and tsconfig
references (the root paths map masked it; the emitted package would import
an undeclared dependency) — wired as peer+dev with the project reference,
module graph regenerated. (2) The structured re-assert only ensured
PRESENCE, so a downstream listener injecting a same-named entry with the
wrong schema kept it model-visible while validateStructuredValue enforced
the real one; it now REPLACES any same-named tool/section with the run's
own. Pinned by a wrong-schema-injection test asserting exactly one entry
carrying the run's schema.
Codex confirmation-round finding: an OUTERMOST prepend pre-execute deny
skips the runtime's own pre-execute clear, and the denied call still
reaches post-execute — so a reused adapter-minted call id could promote an
orphaned stage on the default accept path. The stage is now keyed by the
ToolExecution OBJECT identity, the one token that provably ties a stage to
one pipeline trip: only the execution whose own body staged can commit,
whatever any call id says. The pre-execute clear is gone (one mechanism);
the commit's mismatch drop is now the reachable primary guard. Repro test:
orphaned stage + outer pre-execute deny with the same call id never
promotes; a fresh valid call still captures.
Adversarial-review findings (own reviewer agent), each verified and pinned:
B1: agents.register() returned a wrapper lambda, so the factory composite's
yield could not identity-nest it — on OWNER unload the unregistration (and
agent/disposed) disposed as a concurrent sibling, firing mid-drain while
the final turn was still closing (pre-existing on master; this branch's
docs re-assert the order, so it must be true). register() now returns the
EXACT cordis effect disposer (the Scope.rawDispose move); the composite
nests it and owner unload runs stop/drain -> unregister -> detach -> scope
like every other path. Regression test pins turn-end before disposed
before detach on owner unload.
B2: the structured two-phase commit could promote a stale stage when a
later capture call REUSED the orphaned stage's call id with a body that
never staged (denied downstream, or invalid args throwing pre-stage). The
runtime's pre-execute listener now clears any stale stage unconditionally
when a new capture call enters the pipeline — only a call's own body can
stage for its commit; the call-id mismatch guard becomes a defensive
second layer. Repro test: blocked capture then same-id invalid call.
C1: an explicit empty toolFilter config now fails at plugin LOAD (the
check is self-contained) instead of killing every delegation at child
setup. C2: Scope.dispose/ScopeHost.dispose @returns state the single-shot
repeat-call semantics honestly.
ds-review-bot finding: forcing only the OUTER toolFilter key absent left
the nested arrays materializing — a deny-only config gained allow: [],
which means deny-EVERYTHING. The nested arrays now default to undefined
too; an explicit allow: [] (grant-only children) still survives. Pinned by
a capture-provider regression test.
Cordis effect disposers are single-shot but not await-idempotent: when the
owning fiber's unload invokes the raw wrapper first, a concurrent
handle.dispose() got an immediate undefined and resolved before teardown
finished — violating the driver's stated one-boundary contract (Codex
implementation-review finding). The teardown chain's FIRST-yielded (so
disposed-last) disposer now resolves a shared completion promise; the
handle path awaits it after the wrapper, so tool-finally, parent-teardown,
and owner-unload all observe the same fully-torn-down state. Regression
test: owner unload begins first, concurrent handle.dispose still awaits
unregistration + session detach.