fix(web): address a session's own services from the host
A preset publishes its services behind `isolate` realms, which is what makes them per session — and what makes them invisible to every host context. The api-proxy kept reading the root realm, so requests that are ABOUT a session but arrive from outside it answered for a singleton that no longer exists: `goal.pause`/`clear` and `skill.list` returned "this deployment does not mount @deepseek-ai/dsh-goal / dsh-skill" for sessions whose composition mounts exactly that. Verified against a running host before and after. `agentPresets.serviceFor(agent, name)` addresses the instance instead, reading the same subtree-ownership relation `leakedServices` already uses, inverted. It is read addressing for a caller holding the agent: a host row that `inject`s a service cannot use it, because injection resolves before any session exists — which is why `tools` and `subagents` stay host-plane and this is not a way around that. Tool presenters had the same shape and the same cure: `viewFor` looked definitions up without a scope while the global layer is empty by design, so every card degraded to the generic renderer. It now takes the owning agent. Cold resume through `agentFor()` mounted no preset at all, so every generic entry point — prompt, models, commands — rebuilt a restarted session on host tools and the deployment persona. It composes the recorded preset now, as the other resume path already did.
This commit is contained in:
@@ -414,11 +414,20 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
|
|||||||
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
|
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
|
||||||
* the client's documented default (generic JSON card) covers every miss.
|
* the client's documented default (generic JSON card) covers every miss.
|
||||||
*/
|
*/
|
||||||
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
|
function viewFor(
|
||||||
|
ctx: Context,
|
||||||
|
event: SessionEvent,
|
||||||
|
argsFor: (callId: string) => unknown,
|
||||||
|
// The presenter lives with the definition, and definitions are per agent
|
||||||
|
// now: a preset registers its tools into that agent's layer, leaving the
|
||||||
|
// global layer empty. Looking one up without the owner finds nothing, and
|
||||||
|
// every card silently degrades to the generic renderer.
|
||||||
|
agent?: Agent,
|
||||||
|
): ToolEventView | undefined {
|
||||||
try {
|
try {
|
||||||
if (event.type === 'tool/call') {
|
if (event.type === 'tool/call') {
|
||||||
const { name, arguments: raw } = event.data as ToolCallData
|
const { name, arguments: raw } = event.data as ToolCallData
|
||||||
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
|
const view = ctx.tools.get(name, agent)?.presentCall?.(JSON.parse(raw))
|
||||||
return view === undefined ? undefined : { for: 'call', view }
|
return view === undefined ? undefined : { for: 'call', view }
|
||||||
}
|
}
|
||||||
if (event.type === 'tool/result') {
|
if (event.type === 'tool/result') {
|
||||||
@@ -427,7 +436,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
|
|||||||
const callId = message.source.callId
|
const callId = message.source.callId
|
||||||
const call = argsFor(callId) as { name: string; args: unknown } | undefined
|
const call = argsFor(callId) as { name: string; args: unknown } | undefined
|
||||||
if (call === undefined) return undefined
|
if (call === undefined) return undefined
|
||||||
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
|
const view = ctx.tools.get(call.name, agent)?.presentResult?.(call.args, {
|
||||||
content: result.content,
|
content: result.content,
|
||||||
isError: result.isError === true,
|
isError: result.isError === true,
|
||||||
...meta === undefined ? {} : { meta },
|
...meta === undefined ? {} : { meta },
|
||||||
@@ -470,11 +479,12 @@ function historyPage(
|
|||||||
events: readonly SessionEvent[],
|
events: readonly SessionEvent[],
|
||||||
beforeSeq: number | undefined,
|
beforeSeq: number | undefined,
|
||||||
maxMessages: number | undefined,
|
maxMessages: number | undefined,
|
||||||
|
agent?: Agent,
|
||||||
): { events: HistoryEntry[]; hasMore: boolean } {
|
): { events: HistoryEntry[]; hasMore: boolean } {
|
||||||
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||||
return {
|
return {
|
||||||
events: page.events.map((event) => {
|
events: page.events.map((event) => {
|
||||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), agent)
|
||||||
return { event, ...view === undefined ? {} : { view } }
|
return { event, ...view === undefined ? {} : { view } }
|
||||||
}),
|
}),
|
||||||
hasMore: page.hasMore,
|
hasMore: page.hasMore,
|
||||||
@@ -1090,10 +1100,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
|
if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
|
||||||
throw new SubagentSessionOwnership(sessionId)
|
throw new SubagentSessionOwnership(sessionId)
|
||||||
}
|
}
|
||||||
|
// Cold resume composes the preset the session recorded, for the
|
||||||
|
// same reason `session.create` does: its history was produced under
|
||||||
|
// that composition. Every generic entry point — prompt, models,
|
||||||
|
// commands — arrives here, so leaving it out meant a session opened
|
||||||
|
// after a restart ran on host tools and the deployment persona.
|
||||||
const handle = await ctx.agents.resume({
|
const handle = await ctx.agents.resume({
|
||||||
resumeSessionId: sessionId,
|
resumeSessionId: sessionId,
|
||||||
agentOptions,
|
agentOptions,
|
||||||
setup: installTarget,
|
setup: (await composeAgent(inspected.meta.agentPreset)).setup,
|
||||||
})
|
})
|
||||||
return handle.agent
|
return handle.agent
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1354,11 +1369,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
|
/**
|
||||||
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
* Resolve the goal service THIS agent runs.
|
||||||
const goals = ctx.get('goals')
|
*
|
||||||
|
* The service is per session: an agent preset mounts it behind an `isolate`
|
||||||
|
* realm, which no host context resolves. Reading it from the root would
|
||||||
|
* answer "absent" for a session whose composition mounts it — so the lookup
|
||||||
|
* is keyed by the agent, and only a deployment composing it nowhere is
|
||||||
|
* genuinely absent.
|
||||||
|
*/
|
||||||
|
function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
||||||
|
const presets = ctx.get('agentPresets')
|
||||||
|
const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals')
|
||||||
if (goals === undefined) {
|
if (goals === undefined) {
|
||||||
return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
|
return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } }
|
||||||
}
|
}
|
||||||
return goals
|
return goals
|
||||||
}
|
}
|
||||||
@@ -1374,10 +1398,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
request: RpcRequest<{ sessionId: SessionId }>,
|
request: RpcRequest<{ sessionId: SessionId }>,
|
||||||
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
|
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
|
||||||
): Promise<RpcResponse<{ ref: GoalRef }>> {
|
): Promise<RpcResponse<{ ref: GoalRef }>> {
|
||||||
const goals = goalService()
|
|
||||||
if ('error' in goals) return err(request, goals.error)
|
|
||||||
const found = await agentFor(request.payload.sessionId)
|
const found = await agentFor(request.payload.sessionId)
|
||||||
if ('error' in found) return err(request, found.error)
|
if ('error' in found) return err(request, found.error)
|
||||||
|
const goals = goalServiceFor(found.agent)
|
||||||
|
if ('error' in goals) return err(request, goals.error)
|
||||||
try {
|
try {
|
||||||
const ref = mutation(goals, found.agent)
|
const ref = mutation(goals, found.agent)
|
||||||
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
|
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
|
||||||
@@ -1768,7 +1792,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
details: {},
|
details: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
|
// `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may
|
||||||
|
// serve history from storage with no agent registry composed at all.
|
||||||
|
// An absent registry means no live agent, which is the same answer a
|
||||||
|
// present one gives here — presenters fall back to the global layer.
|
||||||
|
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId))
|
||||||
return ok(request, {
|
return ok(request, {
|
||||||
events: page.events,
|
events: page.events,
|
||||||
hasMore: page.hasMore,
|
hasMore: page.hasMore,
|
||||||
@@ -2071,7 +2099,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
details: { childSessionId },
|
details: { childSessionId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
|
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages, ctx.agents.get(childSessionId))
|
||||||
const projections = beforeSeq === undefined
|
const projections = beforeSeq === undefined
|
||||||
? detachedProjectionsFor(ctx, snapshot.events)
|
? detachedProjectionsFor(ctx, snapshot.events)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -2440,10 +2468,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
},
|
},
|
||||||
|
|
||||||
async clear(request) {
|
async clear(request) {
|
||||||
const goals = goalService()
|
|
||||||
if ('error' in goals) return err(request, goals.error)
|
|
||||||
const found = await agentFor(request.payload.sessionId)
|
const found = await agentFor(request.payload.sessionId)
|
||||||
if ('error' in found) return err(request, found.error)
|
if ('error' in found) return err(request, found.error)
|
||||||
|
const goals = goalServiceFor(found.agent)
|
||||||
|
if ('error' in goals) return err(request, goals.error)
|
||||||
try {
|
try {
|
||||||
goals.clear(found.agent, request.payload.ref)
|
goals.clear(found.agent, request.payload.ref)
|
||||||
return ok(request, { cleared: true as const })
|
return ok(request, { cleared: true as const })
|
||||||
@@ -2473,14 +2501,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||||
}
|
}
|
||||||
const cwd = session.header.cwd
|
const cwd = session.header.cwd
|
||||||
// Same stance as the commands domain: a missing service means the
|
// The registry is per session when a preset mounts one — a preset
|
||||||
// deployment omitted dsh-skill from its composition, not an empty
|
// ships its own skill directory, so the catalog IS the session's — and
|
||||||
// catalog. ctx.get also keeps this handler independent of the gateway
|
// that instance sits behind an `isolate` realm no host context
|
||||||
// plugin's inject list (an undeclared `ctx.skills` property read
|
// resolves. Address it through the live agent; `agents.get` keeps the
|
||||||
// fails the reflect proxy).
|
// no-side-effect stance above (a cold session creates nothing and
|
||||||
const skillRegistry = ctx.get('skills')
|
// falls through to whatever the host composes).
|
||||||
|
const live = ctx.agents.get(sessionId)
|
||||||
|
const presets = ctx.get('agentPresets')
|
||||||
|
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
|
||||||
|
// Same stance as the commands domain: a missing service means no
|
||||||
|
// composition mounts dsh-skill, not an empty catalog. `ctx.get` also
|
||||||
|
// keeps this handler independent of the gateway plugin's inject list
|
||||||
|
// (an undeclared `ctx.skills` property read fails the reflect proxy).
|
||||||
|
const skillRegistry = scoped ?? ctx.get('skills')
|
||||||
if (skillRegistry === undefined) {
|
if (skillRegistry === undefined) {
|
||||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const skills = (await skillRegistry.list({ cwd }))
|
const skills = (await skillRegistry.list({ cwd }))
|
||||||
@@ -2711,8 +2747,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
} else if (event.type === 'turn/end') {
|
} else if (event.type === 'turn/end') {
|
||||||
openCalls.delete(session.id)
|
openCalls.delete(session.id)
|
||||||
}
|
}
|
||||||
const view = viewFor(ctx, event, callId =>
|
const view = viewFor(
|
||||||
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
|
ctx, event,
|
||||||
|
callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId),
|
||||||
|
ctx.agents.get(session.id),
|
||||||
|
)
|
||||||
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
|
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
|
||||||
}),
|
}),
|
||||||
ctx.on('session/created', (session: Session) => {
|
ctx.on('session/created', (session: Session) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
|||||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||||
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||||
|
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||||
import { createApiProxy } from '../src/api-proxy.ts'
|
import { createApiProxy } from '../src/api-proxy.ts'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
@@ -44,9 +45,19 @@ function roster(ids: readonly string[]): unknown {
|
|||||||
},
|
},
|
||||||
mount: (_ctx: Context, id?: string) =>
|
mount: (_ctx: Context, id?: string) =>
|
||||||
Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
|
Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
|
||||||
|
// What a real mount leaves behind: a service instance only the agent that
|
||||||
|
// mounted it can be used to address. The doubles are per agent so a test
|
||||||
|
// can tell "this session's" from "some session's".
|
||||||
|
serviceFor: (agent: { id: unknown }, name: string) => {
|
||||||
|
const perAgent = services.get(String(agent.id))
|
||||||
|
return perAgent?.[name]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-agent service instances a mounted preset would own, keyed by session id. */
|
||||||
|
const services = new Map<string, Record<string, unknown>>()
|
||||||
|
|
||||||
async function harness(presets?: readonly string[]) {
|
async function harness(presets?: readonly string[]) {
|
||||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
@@ -143,3 +154,62 @@ describe('session.create with an agent preset', () => {
|
|||||||
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A capability a preset mounts is reachable from nowhere the host normally
|
||||||
|
* looks: an `isolate` realm is what makes it per session. The gateway serves
|
||||||
|
* requests that are ABOUT a session from OUTSIDE it, so it addresses the
|
||||||
|
* instance through the agent instead of reading a root-realm singleton.
|
||||||
|
*/
|
||||||
|
describe('a capability the session\'s preset mounts', () => {
|
||||||
|
it('serves the goal RPC from the session\'s own goal service', async () => {
|
||||||
|
const { api } = await harness(['standard'])
|
||||||
|
await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
|
||||||
|
const ref = { id: GoalId('goal-1'), revision: 1 }
|
||||||
|
const paused: unknown[] = []
|
||||||
|
services.set('g1', {
|
||||||
|
goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
|
||||||
|
|
||||||
|
expect(response.result).toMatchObject({ ok: true, value: { ref } })
|
||||||
|
// Reached the instance this session mounted, and was handed its own agent.
|
||||||
|
expect(paused).toEqual([['g1', ref]])
|
||||||
|
services.delete('g1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves the skill catalog from the session\'s own registry', async () => {
|
||||||
|
const { api } = await harness(['standard'])
|
||||||
|
await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
|
||||||
|
services.set('k1', {
|
||||||
|
skills: {
|
||||||
|
list: () => Promise.resolve([{
|
||||||
|
name: 'preset-owned',
|
||||||
|
description: 'ships inside the preset directory',
|
||||||
|
invocation: { modelInvocable: true, userInvocable: true },
|
||||||
|
}]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
|
||||||
|
|
||||||
|
// A preset ships its own skill directory, so the catalog IS the
|
||||||
|
// session's; reading a host singleton would answer for the wrong one.
|
||||||
|
expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
|
||||||
|
services.delete('k1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when no composition mounts the capability at all', async () => {
|
||||||
|
const { api } = await harness(['standard'])
|
||||||
|
await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
|
||||||
|
|
||||||
|
const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
|
||||||
|
|
||||||
|
// Absent means absent — not "this session has none", which is what a
|
||||||
|
// root-realm read used to report for every presetd session.
|
||||||
|
expect(response.result.ok).toBe(false)
|
||||||
|
const failure = response.result as { ok: false; error: { message: string } }
|
||||||
|
expect(failure.error.message).toContain('neither this session')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ describe('subagent gateway', () => {
|
|||||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
|
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
it('reads a healthy direct child without acquiring an Agent owner', async () => {
|
||||||
|
// `bench()` leaves the child with no live Agent at all, so the response
|
||||||
|
// below is produced cold — which is the invariant: the read never creates
|
||||||
|
// or resumes one. It may still CONSULT the live registry, because tool
|
||||||
|
// presenters live with the per-agent definitions and rendering this
|
||||||
|
// child's own cards needs its layer.
|
||||||
const { api, getAgent, readSession } = bench()
|
const { api, getAgent, readSession } = bench()
|
||||||
const response = await api.subagents.history(request({
|
const response = await api.subagents.history(request({
|
||||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
|
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
|
||||||
@@ -122,7 +127,7 @@ describe('subagent gateway', () => {
|
|||||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||||
})
|
})
|
||||||
expect(readSession).toHaveBeenCalledWith(CHILD)
|
expect(readSession).toHaveBeenCalledWith(CHILD)
|
||||||
expect(getAgent).not.toHaveBeenCalled()
|
expect(getAgent).not.toHaveBeenCalledWith(PARENT)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { discoverPresets } from './discovery.ts'
|
import { discoverPresets } from './discovery.ts'
|
||||||
import { mountPreset } from './mount.ts'
|
import { mountPreset, serviceForAgent } from './mount.ts'
|
||||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||||
|
|
||||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||||
export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts'
|
export {
|
||||||
|
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount,
|
||||||
|
} from './mount.ts'
|
||||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
|
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
|
||||||
|
|
||||||
@@ -95,6 +97,25 @@ export class AgentPresets extends Service {
|
|||||||
await mountPreset(agentCtx, preset)
|
await mountPreset(agentCtx, preset)
|
||||||
return preset
|
return preset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One agent's instance of a service its preset mounted.
|
||||||
|
*
|
||||||
|
* A preset publishes services behind `isolate` realms, which are invisible
|
||||||
|
* outside the group that declares them — including to the host. This is how a
|
||||||
|
* caller holding the agent reads one anyway: a request that is ABOUT a
|
||||||
|
* session but arrives from outside it, which is every browser RPC.
|
||||||
|
*
|
||||||
|
* Read addressing only. A host row that `inject`s a service cannot use this,
|
||||||
|
* because injection resolves before any session exists and has no agent to
|
||||||
|
* key by; such a service belongs on the host plane instead.
|
||||||
|
* @param agent - the agent whose composition to look inside.
|
||||||
|
* @param name - the service name as the preset's rows resolve it.
|
||||||
|
* @returns the agent's instance, or undefined when its preset mounts none.
|
||||||
|
*/
|
||||||
|
serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined {
|
||||||
|
return serviceForAgent(this.ctx, agent, name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AgentPresets
|
export default AgentPresets
|
||||||
|
|||||||
@@ -155,6 +155,47 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] {
|
|||||||
return leaked.sort((left, right) => left.localeCompare(right))
|
return leaked.sort((left, right) => left.localeCompare(right))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One agent's instance of a service its preset mounted.
|
||||||
|
*
|
||||||
|
* A preset publishes a service behind an `isolate` realm so two sessions
|
||||||
|
* cannot collide, and an entry-local realm is invisible to everything outside
|
||||||
|
* the group — including the agent's own scope context and the host. That is
|
||||||
|
* right for the rows inside the group and wrong for one caller: a request that
|
||||||
|
* is ABOUT a session but arrives from outside it, which is every browser RPC
|
||||||
|
* the api-proxy serves.
|
||||||
|
*
|
||||||
|
* Ownership is the same relation {@link leakedServices} reads, inverted: there
|
||||||
|
* it names implementations a subtree published into the ROOT realm, here it
|
||||||
|
* names the one this subtree published anywhere. Fiber membership is object
|
||||||
|
* identity for the reason stated on {@link withinFiber}.
|
||||||
|
*
|
||||||
|
* This is READ addressing for a caller that already holds the agent. It is not
|
||||||
|
* a general host handle on a session's internals: a host row that `inject`s a
|
||||||
|
* service cannot use it, because injection resolves before any session exists
|
||||||
|
* and has no agent to key by — such a service belongs on the host plane.
|
||||||
|
* @param ctx - any context of the runtime whose service store is inspected.
|
||||||
|
* @param agent - the agent whose mounted composition to look inside.
|
||||||
|
* @param name - the service name as the preset's rows resolve it.
|
||||||
|
* @returns the agent's instance, or undefined when its preset mounts none.
|
||||||
|
*/
|
||||||
|
export function serviceForAgent<K extends string & keyof Context>(
|
||||||
|
ctx: Context,
|
||||||
|
agent: { ctx: Context },
|
||||||
|
name: K,
|
||||||
|
): Context[K] | undefined {
|
||||||
|
const root = agent.ctx.fiber
|
||||||
|
const store = ctx.reflect.store
|
||||||
|
for (const key of Object.getOwnPropertySymbols(store)) {
|
||||||
|
const impl = store[key]
|
||||||
|
/* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
|
||||||
|
if (impl === undefined) continue
|
||||||
|
if (impl.name !== name) continue
|
||||||
|
if (withinFiber(impl.fiber, root)) return impl.value as Context[K]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rows that did not reach a usable state, each rendered as one diagnostic line.
|
* Rows that did not reach a usable state, each rendered as one diagnostic line.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||||
|
|
||||||
|
declare module 'cordis' {
|
||||||
|
interface Context {
|
||||||
|
/** Published by the `isolated` fixture preset behind an entry-local realm. */
|
||||||
|
fixtureIsolatedSvc: { label: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||||
const ROOTS = [
|
const ROOTS = [
|
||||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||||
@@ -155,6 +162,29 @@ describe('rejecting a composition that cannot be used', () => {
|
|||||||
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('addresses one agent\'s instance of a realm-private service', async () => {
|
||||||
|
const first = await agentOn(ctx, 'sess-reach-a', 'isolated')
|
||||||
|
const second = await agentOn(ctx, 'sess-reach-b', 'isolated')
|
||||||
|
|
||||||
|
// The realm keeps the service out of every host context — that is what
|
||||||
|
// makes it per session — so a caller holding the agent is the only way a
|
||||||
|
// request from OUTSIDE the session can read the instance it is about.
|
||||||
|
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||||
|
const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc')
|
||||||
|
const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc')
|
||||||
|
expect(mine).toBeDefined()
|
||||||
|
expect(theirs).toBeDefined()
|
||||||
|
// Each agent gets ITS own: the addressing is per subtree, not a lookup
|
||||||
|
// that happens to find the first match.
|
||||||
|
expect(mine).not.toBe(theirs)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers undefined for a service the agent\'s preset does not mount', async () => {
|
||||||
|
const agent = await agentOn(ctx, 'sess-reach-none', 'standard')
|
||||||
|
|
||||||
|
expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('reports the known ids when a preset is unknown', async () => {
|
it('reports the known ids when a preset is unknown', async () => {
|
||||||
await expect(ctx.agentPresets.resolve('nope'))
|
await expect(ctx.agentPresets.resolve('nope'))
|
||||||
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
|
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
|
||||||
|
|||||||
Reference in New Issue
Block a user