Skip to content

Commit 01f0258

Browse files
See USee U
authored andcommitted
feat(core): harden subagent durable pipeline, MCP tool permissions, and event delivery
- Subagent executor: bound child wait with a timeout, bounded-concurrency event consumption, surface failures as partial instead of completed, escape and frame background steers as untrusted data, and share foreground/background settle. - MCP tools: gate execution behind PermissionV2.assert and make read-only subagents default-deny tools not explicitly allowlisted. - Runner: honor plugin deny/skip tool results, narrow SubagentResult status, reuse coordinator tracking, and clean up fork-only dead code. - Bus: create typed pub/sub channels on publish so early events are buffered for the first subscriber instead of dropped. - Add per-session tool permission overrides and v1 provider availability-list policy migration.
1 parent d325a7f commit 01f0258

25 files changed

Lines changed: 1160 additions & 76 deletions

packages/core/src/bus.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,7 @@ export const layerWith = (options?: LayerOptions) =>
250250
})
251251
}
252252

253-
function commitTransaction(
254-
definition: Definition,
253+
function commitTransaction( definition: Definition,
255254
durable: NonNullable<Definition["durable"]>,
256255
event: Payload,
257256
aggregateID: string,
@@ -405,11 +404,11 @@ export const layerWith = (options?: LayerOptions) =>
405404
version: definition.durable.version,
406405
},
407406
}
408-
yield* notify(event as Payload, true)
407+
yield* notify(definition, event as Payload, true)
409408
return event
410409
}
411410
}
412-
yield* notify(event as Payload, false)
411+
yield* notify(definition, event as Payload, false)
413412
return event
414413
})
415414
}
@@ -422,15 +421,18 @@ export const layerWith = (options?: LayerOptions) =>
422421
),
423422
)
424423

425-
function notify(event: Payload, isolateListeners: boolean) {
424+
function notify(definition: Definition, event: Payload, isolateListeners: boolean) {
426425
return Effect.gen(function* () {
427426
yield* Effect.forEach(
428427
listeners,
429428
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
430429
{ discard: true },
431430
)
432-
const typed = pubsub.typed.get(event.type)
433-
if (typed) yield* PubSub.publish(typed, event)
431+
// Ensure the typed channel exists before publishing so an event published before the
432+
// first subscriber attaches is buffered and delivered to it, rather than dropped. This
433+
// matches the unbounded `all` channel: subscribers observe everything published.
434+
const typed = yield* getOrCreate(definition)
435+
yield* PubSub.publish(typed, event)
434436
yield* PubSub.publish(pubsub.all, event)
435437
})
436438
}
@@ -481,6 +483,7 @@ export const layerWith = (options?: LayerOptions) =>
481483
})
482484
if (committed && options?.publish) {
483485
yield* notify(
486+
definition,
484487
{
485488
...payload,
486489
durable: {

packages/core/src/location-services.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@ import { ReferenceGuidance } from "./reference/guidance"
2727
import * as SessionRunnerLLM from "./session/runner/llm"
2828
import { SessionRunnerModel } from "./session/runner/model"
2929
import { SessionTodo } from "./session/todo"
30+
import { SessionToolPermissions } from "./session/tool-permissions"
3031
import { SkillV2 } from "./skill"
3132
import { SkillGuidance } from "./skill/guidance"
3233
import { Snapshot } from "./snapshot"
3334
import { SubagentRunner } from "./subagent/runner"
3435
import { SystemContextBuiltIns } from "./system-context/builtins"
3536
import { SystemContextRegistry } from "./system-context/registry"
3637
import { BuiltInTools } from "./tool/builtins"
38+
import { MCP } from "./tool/mcp"
3739
import { ReadToolFileSystem } from "./tool/read-filesystem"
3840
import { ToolRegistry } from "./tool/registry"
3941
import { ToolOutputStore } from "./tool-output-store"
@@ -71,9 +73,11 @@ export const locationServices = LayerNode.group([
7173
SkillGuidance.node,
7274
ReferenceGuidance.node,
7375
SessionTodo.node,
76+
SessionToolPermissions.node,
7477
QuestionV2.node,
7578
ReadToolFileSystem.node,
7679
BuiltInTools.node,
80+
MCP.toolNode,
7781
SessionRunnerModel.node,
7882
Snapshot.node,
7983
SubagentRunner.node,

packages/core/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Integration } from "./integration"
1313
import { KeyedMutex } from "./effect/keyed-mutex"
1414
import { PluginHost } from "./plugin/host"
1515
import { Reference } from "./reference"
16+
import { SessionHooks } from "./session/hooks"
1617
import { SkillV2 } from "./skill"
1718
import { State } from "./state"
1819

@@ -162,6 +163,7 @@ export const node = makeLocationNode({
162163
CommandV2.node,
163164
Integration.node,
164165
Reference.node,
166+
SessionHooks.node,
165167
SkillV2.node,
166168
],
167169
})

packages/core/src/plugin/host.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
export * as PluginHost from "./host"
22

33
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
4-
import { Effect, Schema } from "effect"
4+
import { EventManifest } from "@opencode-ai/schema/event-manifest"
5+
import { Effect, Schema, Stream } from "effect"
56
import { AgentV2 } from "../agent"
67
import { AISDK } from "../aisdk"
78
import { Catalog } from "../catalog"
89
import { CommandV2 } from "../command"
910
import { Credential } from "../credential"
11+
import { EventV2 } from "../event"
1012
import { Integration } from "../integration"
1113
import { ModelV2 } from "../model"
1214
import { PluginV2 } from "../plugin"
1315
import { ProviderV2 } from "../provider"
1416
import { Reference } from "../reference"
1517
import type { DeepMutable } from "../schema"
18+
import { SessionHooks } from "../session/hooks"
1619
import { SkillV2 } from "../skill"
1720

1821
const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -22,6 +25,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
2225
const aisdk = yield* AISDK.Service
2326
const catalog = yield* Catalog.Service
2427
const commands = yield* CommandV2.Service
28+
const events = yield* EventV2.Service
29+
const hooks = yield* SessionHooks.Service
2530
const integration = yield* Integration.Service
2631
const reference = yield* Reference.Service
2732
const skill = yield* SkillV2.Service
@@ -100,6 +105,24 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
100105
reload: commands.reload,
101106
transform: commands.transform,
102107
},
108+
event: {
109+
// SDK event type strings are identical to the internal definition types, so
110+
// the public manifest resolves a subscription directly. A few SDK types (e.g.
111+
// server.instance.disposed) are not EventV2 definitions and never emit on the
112+
// durable bus; those hand back an empty stream. Payload data is encoded so
113+
// in-process subscribers receive the same shape as remote SDK consumers.
114+
subscribe: ((type: string) => {
115+
const definition = EventManifest.Latest.get(type)
116+
if (!definition) return Stream.empty
117+
return events.subscribe(definition).pipe(
118+
Stream.map((payload) => ({
119+
id: payload.id,
120+
type: payload.type,
121+
properties: Schema.encodeUnknownSync(definition.data)(payload.data),
122+
})),
123+
)
124+
}) as Interface["event"]["subscribe"],
125+
},
103126
integration: {
104127
reload: integration.reload,
105128
connection: {
@@ -215,5 +238,64 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
215238
}),
216239
),
217240
},
241+
tool: {
242+
// Bridges the public hook context onto the internal SessionHooks seam the
243+
// runner invokes around each tool settlement. before-hooks may rewrite args,
244+
// deny, or skip; after-hooks may append context to the tool result.
245+
hook: ((name: string, callback: (event: unknown) => Effect.Effect<void>) => {
246+
if (name === "execute.before") {
247+
return hooks.registerPreToolUse((tool) =>
248+
Effect.gen(function* () {
249+
let input = tool.input
250+
let denied: string | undefined
251+
let skipped = false
252+
yield* callback({
253+
name: tool.name,
254+
input: tool.input,
255+
args: {
256+
update: (next: unknown) => {
257+
input = next
258+
},
259+
},
260+
deny: (reason: string) => {
261+
denied = reason
262+
},
263+
skip: () => {
264+
skipped = true
265+
},
266+
})
267+
if (denied !== undefined) return { action: "deny" as const, reason: denied }
268+
if (skipped) return { action: "skip" as const }
269+
return input === tool.input
270+
? { action: "allow" as const }
271+
: { action: "allow" as const, modifiedInput: input }
272+
}),
273+
)
274+
}
275+
if (name === "execute.after") {
276+
return hooks.registerPostToolUse((tool) =>
277+
Effect.gen(function* () {
278+
const parts: string[] = []
279+
yield* callback({
280+
name: tool.name,
281+
input: tool.input,
282+
output: tool.output,
283+
context: {
284+
add: (text: string) => {
285+
parts.push(text)
286+
},
287+
},
288+
})
289+
return parts.length
290+
? { action: "continue" as const, additionalContext: parts.join("\n") }
291+
: { action: "continue" as const }
292+
}),
293+
)
294+
}
295+
// Unknown hook names must not silently bridge to a different seam (e.g. a
296+
// typo'd "execute.before" landing on the post hook). Warn and no-op.
297+
return Effect.logWarning("Unknown tool hook name; ignoring", { name }).pipe(Effect.asVoid)
298+
}) as Interface["tool"]["hook"],
299+
},
218300
} satisfies Interface
219301
})

packages/core/src/session.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,12 @@ export type ListInput = typeof ListInput.Type
8686

8787
type CreateInput = {
8888
id?: SessionSchema.ID
89+
parentID?: SessionSchema.ID
90+
title?: string
8991
agent?: AgentV2.ID
9092
model?: ModelV2.Ref
91-
location: Location.Ref
93+
// Optional when parentID is given: the child inherits the parent Session's location.
94+
location?: Location.Ref
9295
}
9396

9497
type CompactInput = {
@@ -244,7 +247,16 @@ const layer = Layer.effect(
244247
const sessionID = input.id ?? SessionSchema.ID.create()
245248
const recorded = yield* store.get(sessionID)
246249
if (recorded) return recorded
247-
const project = yield* projects.resolve(input.location.directory)
250+
// An explicit location wins; otherwise a child inherits its parent's location. The caller
251+
// is trusted here: this process-local API is invoked only by internal services (subagent
252+
// executor, prompt), never by a multi-principal HTTP/event surface. If the event bus or
253+
// session API is ever exposed to multiple principals, parent-location inheritance must be
254+
// gated on an ownership/authorization check.
255+
const parent = input.location === undefined && input.parentID ? yield* store.get(input.parentID) : undefined
256+
const location = input.location ?? parent?.location
257+
if (location === undefined)
258+
return yield* Effect.die(new Error("V2Session.create requires either location or an existing parentID"))
259+
const project = yield* projects.resolve(location.directory)
248260
yield* db
249261
.insert(ProjectTable)
250262
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
@@ -257,10 +269,11 @@ const layer = Layer.effect(
257269
slug: Slug.create(),
258270
version: InstallationVersion,
259271
projectID: project.id,
260-
directory: input.location.directory,
261-
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
262-
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
263-
title: `New session - ${new Date(now).toISOString()}`,
272+
parentID: input.parentID,
273+
directory: location.directory,
274+
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
275+
workspaceID: location.workspaceID ? WorkspaceV2.ID.make(location.workspaceID) : undefined,
276+
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
264277
agent: input.agent,
265278
model: input.model
266279
? {
@@ -274,7 +287,7 @@ const layer = Layer.effect(
274287
time: { created: now, updated: now },
275288
})
276289
const projected = yield* events
277-
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
290+
.publish(SessionV1.Event.Created, { sessionID, info }, { location })
278291
.pipe(
279292
Effect.as({ type: "created" } as const),
280293
Effect.catchDefect((defect) => {

packages/core/src/session/hooks.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * as SessionHooks from "./hooks"
22

3-
import { Context, Effect, Layer, Ref } from "effect"
3+
import { Context, Effect, Layer, Ref, Scope } from "effect"
44
import { makeLocationNode } from "../effect/app-node"
55

66
export type PreToolUseResult =
@@ -25,8 +25,8 @@ export type PostToolUseHook = (tool: {
2525
}) => Effect.Effect<PostToolUseResult>
2626

2727
export interface Interface {
28-
readonly registerPreToolUse: (hook: PreToolUseHook) => Effect.Effect<void>
29-
readonly registerPostToolUse: (hook: PostToolUseHook) => Effect.Effect<void>
28+
readonly registerPreToolUse: (hook: PreToolUseHook) => Effect.Effect<void, never, Scope.Scope>
29+
readonly registerPostToolUse: (hook: PostToolUseHook) => Effect.Effect<void, never, Scope.Scope>
3030
readonly runPreToolUse: (tool: {
3131
readonly name: string
3232
readonly input: unknown
@@ -87,9 +87,11 @@ const layer = Layer.effect(
8787
return Service.of({
8888
registerPreToolUse: Effect.fn("SessionHooks.registerPreToolUse")(function* (hook: PreToolUseHook) {
8989
yield* Ref.update(preHooks, (current) => [...current, hook])
90+
yield* Effect.addFinalizer(() => Ref.update(preHooks, (current) => current.filter((item) => item !== hook)))
9091
}),
9192
registerPostToolUse: Effect.fn("SessionHooks.registerPostToolUse")(function* (hook: PostToolUseHook) {
9293
yield* Ref.update(postHooks, (current) => [...current, hook])
94+
yield* Effect.addFinalizer(() => Ref.update(postHooks, (current) => current.filter((item) => item !== hook)))
9395
}),
9496
runPreToolUse,
9597
runPostToolUse,

packages/core/src/session/runner/model.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * as SessionRunnerModel from "./model"
33
import { makeLocationNode } from "../../effect/app-node"
44
import { type Model } from "@opencode-ai/llm"
55
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
6+
import * as Gemini from "@opencode-ai/llm/protocols/gemini"
67
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
78
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
89
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
@@ -153,6 +154,13 @@ export const fromCatalogModel = (
153154
.model({ id: resolved.api.id }),
154155
)
155156
}
157+
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/google") {
158+
return Effect.succeed(
159+
withDefaults(resolved, Gemini.route)
160+
.with({ auth: key === undefined ? Auth.none : Auth.header("x-goog-api-key", key) })
161+
.model({ id: resolved.api.id }),
162+
)
163+
}
156164
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
157165
return Effect.succeed(
158166
withDefaults(resolved, OpenAICompatibleChat.route)
@@ -176,6 +184,7 @@ export const supported = (model: ModelV2.Info) =>
176184
model.api.type === "aisdk" &&
177185
(model.api.package === "@ai-sdk/openai" ||
178186
model.api.package === "@ai-sdk/anthropic" ||
187+
model.api.package === "@ai-sdk/google" ||
179188
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
180189

181190
/** Resolves models from the catalog belonging to the current Location runtime. */
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export * as SessionToolPermissions from "./tool-permissions"
2+
3+
import { Context, Effect, Layer, Ref } from "effect"
4+
import { makeLocationNode } from "../effect/app-node"
5+
import { PermissionV2 } from "../permission"
6+
import { SessionSchema } from "./schema"
7+
8+
// Per-session tool permission overrides consulted by the runner before falling back to the
9+
// selected agent's permissions. Subagent creation uses this to preserve the read-only subagent
10+
// default without changing the durable runner's agent-permission resolution. In-memory and
11+
// Location-scoped: overrides are cleared once the subagent run that set them completes, so the
12+
// map stays bounded by the set of in-flight subagents rather than the process lifetime.
13+
export interface Interface {
14+
readonly set: (sessionID: SessionSchema.ID, rules: PermissionV2.Ruleset) => Effect.Effect<void>
15+
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<PermissionV2.Ruleset | undefined>
16+
readonly delete: (sessionID: SessionSchema.ID) => Effect.Effect<void>
17+
}
18+
19+
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionToolPermissions") {}
20+
21+
const layer = Layer.effect(
22+
Service,
23+
Effect.gen(function* () {
24+
const overrides = yield* Ref.make(new Map<SessionSchema.ID, PermissionV2.Ruleset>())
25+
return Service.of({
26+
set: (sessionID, rules) => Ref.update(overrides, (current) => new Map(current).set(sessionID, rules)),
27+
get: (sessionID) => Ref.get(overrides).pipe(Effect.map((current) => current.get(sessionID))),
28+
delete: (sessionID) =>
29+
Ref.update(overrides, (current) => {
30+
if (!current.has(sessionID)) return current
31+
const next = new Map(current)
32+
next.delete(sessionID)
33+
return next
34+
}),
35+
})
36+
}),
37+
)
38+
39+
export const node = makeLocationNode({ service: Service, layer, deps: [] })

0 commit comments

Comments
 (0)