From dc26c9e9c5b345ef8a03cdffba900ec72424855b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 15:01:51 +0200 Subject: [PATCH 01/19] feat(agent): prepare trusted runtimes with isolated project tools --- src/agent/hosted/default-chat-runtime.ts | 4 +- .../hosted/executor-project-tools.test.ts | 486 ++++++++++ src/agent/hosted/executor-project-tools.ts | 293 ++++++ .../hosted/executor-runtime-prepare.test.ts | 32 + src/agent/hosted/executor-runtime-prepare.ts | 909 ++---------------- src/agent/hosted/runtime-preparation-core.ts | 889 +++++++++++++++++ .../hosted/trusted-runtime-prepare.test.ts | 443 +++++++++ src/agent/hosted/trusted-runtime-prepare.ts | 89 ++ .../fixtures/trusted-project-executor.ts | 82 ++ .../fixtures/trusted-project/agents/coder.ts | 15 + .../agent/fixtures/trusted-project/probe.ts | 54 ++ .../fixtures/trusted-project/tools/inspect.ts | 34 + .../trusted-project/veryfront.config.ts | 1 + .../fixtures/trusted-runtime-scenario.ts | 286 ++++++ .../agent/trusted-runtime-preparation.test.ts | 52 + 15 files changed, 2817 insertions(+), 852 deletions(-) create mode 100644 src/agent/hosted/executor-project-tools.test.ts create mode 100644 src/agent/hosted/executor-project-tools.ts create mode 100644 src/agent/hosted/runtime-preparation-core.ts create mode 100644 src/agent/hosted/trusted-runtime-prepare.test.ts create mode 100644 src/agent/hosted/trusted-runtime-prepare.ts create mode 100644 tests/integration/agent/fixtures/trusted-project-executor.ts create mode 100644 tests/integration/agent/fixtures/trusted-project/agents/coder.ts create mode 100644 tests/integration/agent/fixtures/trusted-project/probe.ts create mode 100644 tests/integration/agent/fixtures/trusted-project/tools/inspect.ts create mode 100644 tests/integration/agent/fixtures/trusted-project/veryfront.config.ts create mode 100644 tests/integration/agent/fixtures/trusted-runtime-scenario.ts create mode 100644 tests/integration/agent/trusted-runtime-preparation.test.ts diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index e27654a81b..6a83285c6f 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -312,6 +312,8 @@ async function buildToolAssembly( /** @internal Shared runtime construction after transport-free tool assembly. */ export type PreparedHostedRuntimeAgentOptions = { + /** Internal caller identity for a separately prepared trusted project runtime. */ + runtimeAgentId?: string; options: Omit; taskContext: HostedRuntimeStateResolverContext; toolAssembly: HostedChatRuntimeToolAssemblyResult; @@ -333,7 +335,7 @@ function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): Age refreshSystem, }); const runtimeConfig: RuntimeToolFilterConfig = { - id: "veryfront-hosted-runtime", + id: input.runtimeAgentId ?? "veryfront-hosted-runtime", model: input.modelId, system: input.toolAssembly.systemMessages ?? input.toolAssembly.systemInstructions, tools: runtimeTools, diff --git a/src/agent/hosted/executor-project-tools.test.ts b/src/agent/hosted/executor-project-tools.test.ts new file mode 100644 index 0000000000..28723a3bf2 --- /dev/null +++ b/src/agent/hosted/executor-project-tools.test.ts @@ -0,0 +1,486 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; +import { tool } from "#veryfront/tool/factory.ts"; +import type { Tool, ToolExecutionContext } from "#veryfront/tool/types.ts"; +import { + createExecutorChannel, + type ExecutorOperation, +} from "#veryfront/agent/executor/channel.ts"; +import { + createExecutorProjectToolOperations, + createExecutorProjectToolSource, + type ExecutorProjectToolContext, +} from "./executor-project-tools.ts"; + +const binding = { + allocationId: "synthetic-allocation", + generation: 1, + invocationId: "synthetic-invocation", +}; +const fixed = { agentId: "coder", runId: "synthetic-run", projectId: "synthetic-project" }; +const correlation = { toolCallId: "synthetic-call", progressToken: "synthetic-progress" }; +const inputSchema = defineSchema((v) => v.object({ query: v.string() }))(); + +function pair(operations: ReadonlyMap) { + const wire: string[] = []; + const capture = () => + new TransformStream({ + transform(chunk, controller) { + wire.push(new TextDecoder().decode(chunk)); + controller.enqueue(chunk); + }, + }); + const forward = capture(); + const backward = capture(); + const trusted = createExecutorChannel({ + binding, + transport: { readable: backward.readable, writable: forward.writable }, + }); + const project = createExecutorChannel({ + binding, + operations, + transport: { readable: forward.readable, writable: backward.writable }, + }); + return { + trusted, + project, + wire, + async close() { + trusted.close(); + await Promise.all([trusted.settled, project.settled]); + }, + }; +} + +function fixture( + execute: Tool["execute"] = (args) => Promise.resolve(args), + overrides: Partial[0]> = {}, +) { + const lifetime = new AbortController(); + let revoked = false; + const assertActive = () => { + if (revoked) throw new TypeError("Synthetic revoked scope"); + }; + const registered = tool({ id: "inspect", description: "Inspect a query", inputSchema, execute }); + const tools = new Map([["inspect", registered]]); + const allowedToolNames = new Set(["inspect"]); + const context = { ...fixed }; + const operations = createExecutorProjectToolOperations({ + scope: { binding, signal: lifetime.signal, assertActive }, + context, + tools, + allowedToolNames, + maxCalls: 32, + maxConcurrent: 2, + ...overrides, + }); + const channels = pair(operations); + return { + ...channels, + lifetime, + context, + tools, + allowedToolNames, + registered, + operations, + revoke() { + revoked = true; + }, + source(options: Partial[0]> = {}) { + return createExecutorProjectToolSource({ + channel: channels.trusted, + signal: lifetime.signal, + context: { ...fixed }, + allowedToolNames: new Set(["inspect"]), + assertActive, + ...options, + }); + }, + }; +} + +describe("executor project tools", () => { + it("executes through the channel with only fixed identity and local call adapters", async () => { + let executions = 0; + const f = fixture(async (args, context) => { + executions++; + assert(context?.abortSignal instanceof AbortSignal); + assertEquals(typeof context.publishDataEvent, "function"); + return { + query: args.query, + fields: Object.keys(context).sort(), + agentId: context.agentId, + runId: context.runId, + projectId: context.projectId, + toolCallId: context.toolCallId, + progressToken: context.progressToken, + }; + }); + try { + assertEquals([...f.operations.keys()], [ + "tool.sources", + "tool.list", + "tool.execute", + "project.tool-aliases", + ]); + const source = await f.source(); + assertEquals(source.id, "project"); + assertEquals((await source.listTools()).map((item) => item.name), ["inspect"]); + const context: ToolExecutionContext = { + ...fixed, + ...correlation, + authToken: "", + unrelatedPrivateField: "", + }; + Object.defineProperty(context, "privateGetter", { + enumerable: true, + get() { + throw new Error("Private context read"); + }, + }); + assertEquals(await source.executeTool("inspect", { query: "hello" }, context), { + query: "hello", + fields: [ + "abortSignal", + "agentId", + "progressToken", + "projectId", + "publishDataEvent", + "runId", + "toolCallId", + ], + ...fixed, + ...correlation, + }); + assertEquals(executions, 1); + for ( + const marker of [ + "", + "", + "authToken", + "unrelatedPrivateField", + "privateGetter", + ] + ) { + assert(!f.wire.join("").includes(marker)); + } + await assertRejects(() => source.executeTool("inspect", { query: 7 }, correlation)); + assertEquals(executions, 1); + } finally { + await f.close(); + } + }); + + it("rejects conflicting explicit identities and missing or malformed call correlation before dispatch", async () => { + let executions = 0; + const f = fixture(async () => ++executions); + try { + const source = await f.source(); + const before = f.wire.length; + for (const key of ["agentId", "runId", "projectId"]) { + await assertRejects(() => + source.executeTool("inspect", { query: "hello" }, { ...correlation, [key]: "other" }) + ); + await assertRejects(() => source.listTools({ [key]: "other" })); + } + for ( + const context of [undefined, {}, { toolCallId: "" }, { toolCallId: 1 }, { + ...correlation, + progressToken: {}, + }] + ) { + await assertRejects(() => + source.executeTool("inspect", { query: "hello" }, context as ToolExecutionContext) + ); + } + await assertRejects(() => source.executeTool("unknown", { query: "hello" }, correlation)); + assertEquals(f.wire.length, before); + assertEquals(executions, 0); + } finally { + await f.close(); + } + }); + + it("filters owner-invisible and skill infrastructure tools and uses registered names", async () => { + const make = (id: string, ownerAgentId?: string): Tool => ({ + ...tool({ id, description: "Synthetic tool", inputSchema, execute: (args) => args }), + ownerAgentId, + }); + const tools = new Map([ + ["registered", make("internal", "coder")], + ["hidden", make("hidden", "other")], + ["load_skill", make("load_skill")], + ]); + const allowedToolNames = new Set(["registered", "hidden", "load_skill"]); + const f = fixture(undefined, { tools, allowedToolNames }); + try { + const source = await f.source({ allowedToolNames }); + assertEquals((await source.listTools()).map((item) => item.name), ["registered"]); + assertEquals(await source.executeTool("registered", { query: "hello" }, correlation), { + query: "hello", + }); + for (const name of ["hidden", "load_skill", "internal"]) { + await assertRejects(() => source.executeTool(name, { query: "hello" }, correlation)); + } + } finally { + await f.close(); + } + }); + + it("snapshots both construction contexts, grants, tool callbacks and detached descriptors", async () => { + const f = fixture(); + const context = { ...fixed }; + const allowedToolNames = new Set(["inspect"]); + try { + const pending = f.source({ context, allowedToolNames }); + context.agentId = "other"; + allowedToolNames.clear(); + allowedToolNames.add("ungranted"); + f.context.projectId = "other"; + f.allowedToolNames.clear(); + f.tools.clear(); + f.registered.execute = async () => "mutated"; + f.registered.description = "mutated"; + const source = await pending; + const first = await source.listTools(); + first[0]!.name = "mutated"; + first[0]!.parameters.type = "string"; + first.push({ name: "ungranted", description: "", parameters: {} }); + assertEquals((await source.listTools()).map((item) => item.name), ["inspect"]); + assertEquals((await source.listTools())[0]!.parameters.type, "object"); + assertEquals( + await source.executeTool("inspect", { query: "hello" }, { ...fixed, ...correlation }), + { query: "hello" }, + ); + await assertRejects(() => source.executeTool("ungranted", {}, correlation)); + } finally { + await f.close(); + } + }); + + it("retains owner aliases when a global tool has the same short name", async () => { + const globalTool = tool({ + id: "inspect", + description: "Global inspection", + inputSchema, + execute: () => "global", + }); + const owned = tool({ + id: "coder--inspect", + description: "Owned inspection", + inputSchema, + execute: () => "owned", + }); + owned.ownerAgentId = "coder"; + owned.shortName = "inspect"; + const allowedToolNames = new Set(["inspect", "coder--inspect"]); + const f = fixture(undefined, { + tools: new Map([["inspect", globalTool], ["coder--inspect", owned]]), + allowedToolNames, + }); + try { + const source = await f.source({ allowedToolNames }); + assertEquals(source.aliases, [{ name: "coder--inspect", shortName: "inspect" }]); + assertEquals( + await source.executeTool("coder--inspect", { query: "hello" }, correlation), + "owned", + ); + assertEquals(await source.executeTool("inspect", { query: "hello" }, correlation), "global"); + } finally { + await f.close(); + } + }); + + it("rejects extra or malformed construction context on either adapter", async () => { + const f = fixture(); + try { + for ( + const context of [ + { ...fixed, authToken: "" }, + { ...fixed, optionalRequirement: true }, + { ...fixed, agentId: "" }, + { ...fixed, runId: 1 }, + { ...fixed, projectId: "x".repeat(257) }, + { agentId: "coder", runId: "synthetic-run" }, + ] + ) { + assertThrows(() => + createExecutorProjectToolOperations({ + scope: { binding, signal: f.lifetime.signal, assertActive() {} }, + context: context as ExecutorProjectToolContext, + tools: f.tools, + allowedToolNames: f.allowedToolNames, + maxCalls: 8, + maxConcurrent: 1, + }) + ); + await assertRejects(() => f.source({ context: context as ExecutorProjectToolContext })); + } + } finally { + await f.close(); + } + }); + + it("rejects revoked authority before dispatch and before accepting a late result", async () => { + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + let active = true; + let executions = 0; + const f = fixture(async () => { + executions++; + started.resolve(); + await finish.promise; + return "late"; + }); + try { + const source = await f.source({ + assertActive() { + if (!active) throw new TypeError("Revoked"); + }, + }); + const result = source.executeTool("inspect", { query: "hello" }, correlation); + await started.promise; + active = false; + finish.resolve(); + await assertRejects(() => result); + await assertRejects(() => source.executeTool("inspect", { query: "hello" }, correlation)); + await assertRejects(() => source.listTools()); + f.revoke(); + await assertRejects(() => f.source()); + assertEquals(executions, 1); + } finally { + finish.resolve(); + await f.close(); + } + }); + + it("does not expose privileged operations to the project peer", async () => { + const f = fixture(); + try { + await f.source(); + await assertRejects(() => f.project.request("model.prepare", {})); + await assertRejects(() => f.project.request("runtime.prepare", {})); + } finally { + await f.close(); + } + }); + + for ( + const problem of ["extra source", "duplicate source", "malformed descriptor", "duplicate tool"] + ) { + it(`rejects ${problem} from a project peer`, async () => { + const descriptor = { + name: "inspect", + description: "Synthetic", + parameters: { type: "object" }, + }; + const stream = (frames: JsonValue[]): ExecutorOperation => ({ + mode: "stream", + async *handle() { + yield* frames; + }, + }); + const sources: JsonValue[] = [{ type: "source", sourceId: "project" }]; + if (problem.endsWith("source")) { + sources.push({ + type: "source", + sourceId: problem === "extra source" ? "other" : "project", + }); + } + const tools: JsonValue[] = [{ + type: "tool", + definition: problem === "malformed descriptor" + ? { ...descriptor, parameters: [] } + : descriptor, + }]; + if (problem === "duplicate tool") tools.push(tools[0]!); + const f = pair( + new Map([ + ["tool.sources", stream([...sources, { type: "complete" }])], + ["tool.list", stream([...tools, { type: "complete" }])], + ]), + ); + try { + await assertRejects(() => + createExecutorProjectToolSource({ + channel: f.trusted, + signal: new AbortController().signal, + context: fixed, + allowedToolNames: new Set(["inspect"]), + assertActive() {}, + }) + ); + } finally { + await f.close(); + } + }); + } + + it("bounds progress and results without retrying a tool side effect", async () => { + const progress: unknown[] = []; + let executions = 0; + const f = fixture(async (_args, context) => { + executions++; + await context!.publishDataEvent!({ type: "synthetic-progress", data: { percent: 50 } }); + return { oversized: "x".repeat(100) }; + }, { limits: { maxResultBytes: 32 } }); + try { + const source = await f.source(); + await assertRejects(() => + source.executeTool("inspect", { query: "hello" }, { + ...correlation, + publishDataEvent: (event) => { + progress.push(event); + }, + }) + ); + assertEquals(progress, [{ type: "synthetic-progress", data: { percent: 50 } }]); + assertEquals(executions, 1); + } finally { + await f.close(); + } + }); + + it("retains original tool work after cancellation until it actually settles", async () => { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const finish = Promise.withResolvers(); + let executions = 0; + const f = fixture(async (_args, context) => { + executions++; + context!.abortSignal!.addEventListener("abort", () => aborted.resolve(), { once: true }); + started.resolve(); + await finish.promise; + return "late"; + }); + try { + const source = await f.source(); + const controller = new AbortController(); + const result = source.executeTool("inspect", { query: "hello" }, { + ...correlation, + abortSignal: controller.signal, + }); + const rejected = assertRejects(() => result); + await started.promise; + controller.abort(); + await aborted.promise; + let retired = false; + void f.project.settled.then(() => { + retired = true; + }); + f.trusted.close(); + await f.trusted.closed; + assertEquals(retired, false, "A closed channel does not release the original project work"); + finish.resolve(); + await rejected; + await Promise.all([f.trusted.settled, f.project.settled]); + assertEquals(retired, true); + assertEquals(executions, 1); + } finally { + finish.resolve(); + await f.close(); + } + }); +}); diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts new file mode 100644 index 0000000000..401413bc5d --- /dev/null +++ b/src/agent/hosted/executor-project-tools.ts @@ -0,0 +1,293 @@ +import { defineSchema } from "#veryfront/schemas/index.ts"; +import type { ExecutorChannel, ExecutorOperation } from "#veryfront/agent/executor/channel.ts"; +import { + type ExecutorBinding, + getExecutorBindingSchema, +} from "#veryfront/agent/executor/protocol.ts"; +import type { RemoteToolSource, Tool, ToolExecutionContext } from "#veryfront/tool/types.ts"; +import { isToolVisibleTo } from "#veryfront/tool/executor.ts"; +import { toolToProviderDefinition } from "#veryfront/tool/registry.ts"; +import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; +import { createExecutorToolBroker } from "#veryfront/agent/hosted/executor-tool-bridge.ts"; +import { createExecutorRemoteToolSources } from "#veryfront/agent/hosted/executor-tool-remote-facade.ts"; +import { + EXECUTOR_TOOL_LIMITS, + executorToolDefinition, + type ExecutorToolLimits, + executorToolLimits, + getExecutorToolCallSchema, + getExecutorToolEmptySchema, + getExecutorToolIdSchema, + getExecutorToolListSchema, + parseExecutorToolData, +} from "#veryfront/agent/hosted/executor-tool-schema.ts"; + +export const EXECUTOR_PROJECT_TOOL_SOURCE_ID = "project"; +const TOOL_ALIASES_OPERATION = "project.tool-aliases"; + +export interface ExecutorProjectToolSource extends RemoteToolSource { + readonly aliases: readonly { readonly name: string; readonly shortName: string }[]; +} + +export interface ExecutorProjectToolContext { + agentId: string; + runId: string; + projectId: string; +} + +const getContextSchema = defineSchema((v) => + v.object({ + agentId: getExecutorToolIdSchema(), + runId: getExecutorToolIdSchema(), + projectId: getExecutorToolIdSchema(), + }).strict() +); + +const getAliasesSchema = defineSchema((v) => + v.object({ + agentId: getExecutorToolIdSchema(), + aliases: v.array( + v.object({ name: getExecutorToolIdSchema(), shortName: getExecutorToolIdSchema() }).strict(), + ).max(EXECUTOR_TOOL_LIMITS.maxToolsPerSource), + }).strict() +); + +export interface ExecutorProjectToolOperationsOptions { + scope: { binding: ExecutorBinding; signal: AbortSignal; assertActive(): void }; + context: ExecutorProjectToolContext; + tools: ReadonlyMap; + allowedToolNames: ReadonlySet; + maxCalls: number; + maxConcurrent: number; + limits?: Partial; +} + +export interface ExecutorProjectToolSourceOptions { + channel: ExecutorChannel; + signal: AbortSignal; + context: ExecutorProjectToolContext; + allowedToolNames: ReadonlySet; + assertActive(): void; + limits?: Partial; +} + +function captureNames(names: ReadonlySet, limits: ExecutorToolLimits): Set { + const result = new Set(); + for (const name of names) { + if (result.size >= limits.maxToolsPerSource) { + throw new TypeError("Project tool allowlist exceeds its limit"); + } + result.add(parseExecutorToolData(getExecutorToolIdSchema(), name)); + } + return result; +} + +/** Own selected call fields only; unrelated host context is never enumerated. */ +function callField( + context: ToolExecutionContext | undefined, + key: K, +): ToolExecutionContext[K] { + if (context === undefined) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(context, key); + if (!descriptor) return undefined; + if (!Object.hasOwn(descriptor, "value")) throw new TypeError("Invalid project tool call context"); + return descriptor.value; +} + +/** Install only project-tool operations. Construction receives no private host capabilities. */ +export function createExecutorProjectToolOperations( + options: ExecutorProjectToolOperationsOptions, +): ReadonlyMap { + const fixed = Object.freeze(parseExecutorToolData(getContextSchema(), options.context)); + const limits = executorToolLimits(options.limits); + const allowed = captureNames(options.allowedToolNames, limits); + if (options.tools.size > limits.maxTotalTools) { + throw new TypeError("Project tool catalog exceeds its limit"); + } + const catalog = new Map; + execute: Tool["execute"]; + }>(); + const aliases: { name: string; shortName: string }[] = []; + for (const [name, registered] of options.tools) { + if ( + !allowed.has(name) || isSkillInfrastructureToolId(name) || + !isToolVisibleTo(registered, { agentId: fixed.agentId }) + ) continue; + if (typeof registered.execute !== "function") throw new TypeError("Invalid project tool"); + const execute = registered.execute.bind(registered); + const definition = executorToolDefinition({ + ...toolToProviderDefinition(registered), + name, + }, limits); + catalog.set(name, { definition, execute }); + if (registered.ownerAgentId === fixed.agentId && registered.shortName !== undefined) { + aliases.push({ + name, + shortName: parseExecutorToolData(getExecutorToolIdSchema(), registered.shortName), + }); + } + } + const source: RemoteToolSource = { + id: EXECUTOR_PROJECT_TOOL_SOURCE_ID, + listTools: () => + Promise.resolve( + [...catalog.values()].map(({ definition }) => executorToolDefinition(definition, limits)), + ), + async executeTool(name, args, context) { + const selected = catalog.get(name); + if (!selected || !context?.toolCallId) { + throw new TypeError("Project tool call is not allowed"); + } + return await selected.execute(args, { + ...fixed, + toolCallId: context.toolCallId, + ...(context.progressToken === undefined ? {} : { progressToken: context.progressToken }), + abortSignal: context.abortSignal, + publishDataEvent: context.publishDataEvent, + }); + }, + }; + const operations = new Map(createExecutorToolBroker({ + scope: options.scope, + sources: new Map([[source.id, { + source, + allowedToolNames: new Set(catalog.keys()), + context: fixed, + }]]), + maxCalls: options.maxCalls, + maxConcurrent: options.maxConcurrent, + limits, + })); + const binding = parseExecutorToolData(getExecutorBindingSchema(), options.scope.binding); + const signal = options.scope.signal; + const assertActive = options.scope.assertActive.bind(options.scope); + let described = false; + operations.set(TOOL_ALIASES_OPERATION, { + mode: "unary", + handle(value, context) { + parseExecutorToolData(getExecutorToolEmptySchema(), value); + assertActive(); + signal.throwIfAborted(); + context.signal.throwIfAborted(); + if ( + described || context.deadline <= Date.now() || + binding.allocationId !== context.binding.allocationId || + binding.invocationId !== context.binding.invocationId || + binding.generation !== context.binding.generation + ) { + throw new TypeError("Project tool metadata is unavailable"); + } + described = true; + return parseExecutorToolData(getAliasesSchema(), { agentId: fixed.agentId, aliases }); + }, + }); + return operations; +} + +/** Trusted peer adapter. Peer metadata never enlarges the invocation's tool grant. */ +export async function createExecutorProjectToolSource( + options: ExecutorProjectToolSourceOptions, +): Promise { + const fixed = Object.freeze(parseExecutorToolData(getContextSchema(), options.context)); + const limits = executorToolLimits(options.limits); + const allowed = captureNames(options.allowedToolNames, limits); + const { channel, signal, assertActive } = options; + const check = () => { + assertActive(); + signal.throwIfAborted(); + channel.signal.throwIfAborted(); + }; + const projectContext = (context?: ToolExecutionContext): ToolExecutionContext => { + check(); + for (const key of ["agentId", "runId", "projectId"] as const) { + const requested = callField(context, key); + if (requested !== undefined && requested !== fixed[key]) { + throw new TypeError("Project tool call identity mismatch"); + } + } + const toolCallId = callField(context, "toolCallId"); + const progressToken = callField(context, "progressToken"); + const correlation = parseExecutorToolData(getExecutorToolListSchema(), { + sourceId: EXECUTOR_PROJECT_TOOL_SOURCE_ID, + ...(toolCallId === undefined ? {} : { toolCallId }), + ...(progressToken === undefined ? {} : { progressToken }), + }); + const abortSignal = callField(context, "abortSignal"); + abortSignal?.throwIfAborted(); + const publish = callField(context, "publishDataEvent"); + return { + toolCallId: correlation.toolCallId, + progressToken: correlation.progressToken, + abortSignal, + ...(publish === undefined ? {} : { + publishDataEvent: async (event) => { + check(); + await publish.call(context, event); + check(); + }, + }), + }; + }; + check(); + const sources = await createExecutorRemoteToolSources({ channel, signal, limits }); + check(); + if (sources.length !== 1 || sources[0]?.id !== EXECUTOR_PROJECT_TOOL_SOURCE_ID) { + throw new TypeError("Invalid project tool source"); + } + const remote = sources[0]; + const definitions = await remote.listTools(); + check(); + const metadata = parseExecutorToolData( + getAliasesSchema(), + await channel.request(TOOL_ALIASES_OPERATION, {}, { signal }), + ); + check(); + if (metadata.agentId !== fixed.agentId) { + throw new TypeError("Project tool metadata owner mismatch"); + } + const catalog = new Map( + definitions.filter((definition) => allowed.has(definition.name)).map( + (definition) => [definition.name, definition] as const, + ), + ); + const aliases = new Map(); + for (const entry of metadata.aliases) { + if ( + !definitions.some((definition) => definition.name === entry.name) || + aliases.has(entry.shortName) + ) { + throw new TypeError("Invalid project tool aliases"); + } + aliases.set(entry.shortName, entry.name); + } + return Object.freeze({ + id: EXECUTOR_PROJECT_TOOL_SOURCE_ID, + aliases: Object.freeze( + [...aliases].filter(([, name]) => catalog.has(name)).map(([shortName, name]) => + Object.freeze({ name, shortName }) + ), + ), + async listTools(context?: ToolExecutionContext) { + projectContext(context); + return [...catalog.values()].map((definition) => executorToolDefinition(definition, limits)); + }, + async executeTool(name: string, args: Record, context?: ToolExecutionContext) { + const call = projectContext(context); + if (!call.toolCallId || !catalog.has(name)) { + throw new TypeError("Project tool call is not allowed"); + } + // Validate locally before writing even one frame to the project connection. + parseExecutorToolData(getExecutorToolCallSchema(), { + sourceId: EXECUTOR_PROJECT_TOOL_SOURCE_ID, + toolName: name, + args, + toolCallId: call.toolCallId, + ...(call.progressToken === undefined ? {} : { progressToken: call.progressToken }), + }); + const result = await remote.executeTool(name, args, call); + check(); + return result; + }, + }); +} diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 8ec67cec19..47fe74724d 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -204,6 +204,38 @@ describe("executor runtime preparation", () => { }); } + it("normalizes a synchronous discovery result before private promise observation", async () => { + const f = fixture(); + const context = { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }; + const describe = f.discovery.operations.get("agent.describe"); + assert(describe?.mode === "unary"); + const description = await describe.handle({ agentId: "coder" }, context); + (f.discovery.operations as Map< + string, + import("#veryfront/agent/executor/channel.ts").ExecutorOperation + >).set("agent.describe", { mode: "unary", handle: () => description }); + let timer: ReturnType | undefined; + let completed = false; + try { + const result = await Promise.race([ + prepare(f.owner), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), 500); + }), + ]); + completed = result !== null; + assert(completed, "Synchronous discovery must not leave preparation pending"); + assertEquals((result as { ok: boolean }).ok, true); + } finally { + clearTimeout(timer); + if (completed) await f.owner.close(); + else void f.owner.close().catch(() => {}); + } + }); for (const selection of [undefined, [], ["load_skill"]]) { it(`normalizes implicit disabled skill tools while retaining explicit rejection (${JSON.stringify(selection)})`, async () => { const f = fixture({ diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 899c1d48aa..17f224c92a 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -1,204 +1,30 @@ -import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; -import { createPrivateSet } from "#veryfront/security/private-set.ts"; -import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; -import { - chainPrivatePromise as chain, - createPrivateDeferred, - observePrivatePromise, - resolvePrivatePromise, -} from "#veryfront/security/private-promise.ts"; -import type { JsonValue } from "#veryfront/schemas/index.ts"; -import { - resolveVeryfrontCloudModelThinking, - resolveVeryfrontCloudReasoningOption, - resolveVeryfrontCloudThinkingProviderOptions, - tryGetVeryfrontCloudProviderFromModelId, - VERYFRONT_CLOUD_MODEL_PREFIX, -} from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; -import { getExecutorModelAdditiveReasoningTokens } from "#veryfront/agent/hosted/executor-model-grant.ts"; -import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; -import { isToolVisibleTo } from "#veryfront/tool"; -import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; -import type { AgentSystem } from "#veryfront/agent/types.ts"; -import { - type AgentModelRuntimeResolver, - registerModelRuntimeResolverRevoker, - revokeModelRuntimeResolver, -} from "#veryfront/agent/runtime/model-transport.ts"; -import { wrapRemoteToolSourceWithMcpPolicy } from "#veryfront/agent/mcp-tool-policy.ts"; -import type { RuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; -import { - type ExecutorBinding, - getExecutorBindingSchema, -} from "#veryfront/agent/executor/protocol.ts"; -import type { - ExecutorOperation, - ExecutorOperationContext, -} from "#veryfront/agent/executor/channel.ts"; +import type { ExecutorBinding } from "#veryfront/agent/executor/protocol.ts"; import type { ExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; +import type { ExecutorDiscoverySource } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { ExecutorRuntimePreparationError } from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; +import { createPreparedHostedRuntimeAgent } from "#veryfront/agent/hosted/default-chat-runtime.ts"; import { - type ExecutorDiscoverySource, - getExecutorAgentDescribeResultSchema, - getExecutorDiscoverySourceSchema, - parseDiscoveryData, -} from "#veryfront/agent/hosted/executor-discovery-schema.ts"; -import { verifyHostedRuntimeSourceBinding } from "#veryfront/agent/hosted/runtime-source-binding.ts"; -import { - resolveHostedRuntimeAllowedProviderTools, - resolveHostedRuntimeAllowedTools, -} from "#veryfront/agent/hosted/runtime-request-config.ts"; -import { resolveHostedRuntimeAllowedToolNames } from "#veryfront/agent/hosted/runtime-essential-tools.ts"; -import { - executorAgentFailureCode, - executorAgentJson, -} from "#veryfront/agent/hosted/executor-agent-schema.ts"; -import { createExecutorAgentOperations } from "#veryfront/agent/hosted/executor-agent-bridge.ts"; -import { createHostedChatRuntimeDataStream } from "#veryfront/agent/hosted/chat-runtime-agent-adapter.ts"; -import { - createPreparedHostedRuntimeAgent, - incrementSteeringRevision, - type PreparedHostedRuntimeAgentOptions, - scopeHostedRuntimeToolResults, -} from "#veryfront/agent/hosted/default-chat-runtime.ts"; -import { - prepareFacadedHostedChatRuntimeToolAssembly, - resolveOwnerScopedToolNames, -} from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; -import type { - HostedChatRuntimeCreationOptions, - HostedChatRuntimeProjectSteering, -} from "#veryfront/agent/hosted/chat-runtime-contract.ts"; -import type { RuntimeAgentThinkingConfig } from "#veryfront/agent/runtime/agent-definition.ts"; -import { resolveRuntimeSkillSelectorForAgent } from "#veryfront/agent/runtime/skill-metadata.ts"; -import { runWithProjectAgentRuntime } from "#veryfront/agent/project/agent-runtime.ts"; -import { - applyDefaultResearchArtifactPath, - shouldRetryCreateResearchArtifactAsUpdate, -} from "#veryfront/agent/artifacts/default-research-artifact-support.ts"; -import { buildInteractiveVeryfrontCloudRuntimeInstructions } from "#veryfront/agent/hosted/cloud-runtime-system-messages.ts"; + type ProjectAgentRuntimeDiscovery, + runWithProjectAgentRuntime, +} from "#veryfront/agent/project/agent-runtime.ts"; +import { isToolVisibleTo } from "#veryfront/tool/executor.ts"; +import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; +import { filterPrivateArray } from "#veryfront/security/private-array.ts"; +import { chainPrivatePromise, resolvePrivatePromise } from "#veryfront/security/private-promise.ts"; import { - type ExecutorRuntimeGrantData, - ExecutorRuntimePreparationError, - type ExecutorRuntimePrepareRequest, - getExecutorRuntimeGrantDataSchema, - getExecutorRuntimePrepareRequestSchema, - getExecutorRuntimeSteeringSchema, - parseRuntimePreparationData, -} from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; - -const apply = Reflect.apply; -const mapGet = Map.prototype.get; -const mapHas = Map.prototype.has; -const hasOwn = Object.hasOwn; -const objectSetPrototypeOf = Object.setPrototypeOf; -const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const objectGetPrototypeOf = Object.getPrototypeOf; -const objectPrototype = Object.prototype; -const objectEntries = Object.entries; -const arrayIncludes = Array.prototype.includes; -const arrayIsArray = Array.isArray; -const abortController = AbortController.prototype.abort; -const abortSignalAny = AbortSignal.any; -const AbortSignalConstructor = AbortSignal; -const mathMin = Math.min; -const numberIsSafeInteger = Number.isSafeInteger; -const addEventListener = EventTarget.prototype.addEventListener; -const removeEventListener = EventTarget.prototype.removeEventListener; -const iteratorSymbol = Symbol.iterator; + createRuntimePreparationCore, + type ExecutorRuntimeFacades, + type ExecutorRuntimePreparationGrant, +} from "#veryfront/agent/hosted/runtime-preparation-core.ts"; -function combineSignals(...signals: AbortSignal[]): AbortSignal { - const inputs = createPrivateSet(signals); - defineOwnDataProperty(signals, iteratorSymbol, () => inputs.values()); - return apply(abortSignalAny, AbortSignalConstructor, [signals]) as AbortSignal; -} - -function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { - const filtered: T[] = []; - for (let index = 0; index < values.length; index++) { - const value = values[index] as T; - if (!predicate(value)) continue; - defineOwnDataProperty( - filtered, - filtered.length, - value, - { enumerable: true, configurable: true, writable: true }, - ); - } - return filtered; -} -function includes(values: readonly T[], value: T): boolean { - return apply(arrayIncludes, values, [value]) as boolean; -} - -function privateMapGet(map: ReadonlyMap, key: K): V | undefined { - return apply(mapGet, map, [key]) as V | undefined; -} -function privateMapHas(map: ReadonlyMap, key: K): boolean { - return apply(mapHas, map, [key]) as boolean; -} +export type { + ExecutorRuntimeFacades, + ExecutorRuntimePreparationGrant, +} from "#veryfront/agent/hosted/runtime-preparation-core.ts"; -function selectAllowedHostTools( - tools: HostToolSet, - allowedNames: readonly string[], -): HostToolSet { - const allowed = createPrivateSet(allowedNames); - const entries = apply(objectEntries, Object, [tools]) as Array< - [string, HostToolSet[string]] - >; - const selected: HostToolSet = {}; - for (let index = 0; index < entries.length; index++) { - const entry = entries[index]; - if (entry === undefined || !allowed.has(entry[0])) continue; - defineOwnDataProperty( - selected, - entry[0], - entry[1], - { enumerable: true, configurable: true, writable: true }, - ); - } - return selected; -} +const mapGet = Map.prototype.get; +const apply = Reflect.apply; -type CreationOptions = HostedChatRuntimeCreationOptions< - RuntimeAgentMarkdownDefinition, - RuntimeAgentThinkingConfig ->; -export type ExecutorRuntimePreparationGrant = Omit & { - /** Preparation selections only. Invocation-wide call accounting is enforced by the broker. */ - models: ReadonlyMap; -}; -export interface ExecutorRuntimeFacades { - /** Must be the invocation's granted model proxy; missing models throw instead of using provider defaults. */ - resolveModelRuntime: AgentModelRuntimeResolver; - hostTools: ReadonlyMap; - remoteToolSources: ReadonlyMap; - projectSteering?: { - prepare( - input: { - definition: RuntimeAgentMarkdownDefinition; - projectId: string | null; - branchId?: string | null; - signal: AbortSignal; - }, - ): Promise>; - refresh( - signal: AbortSignal, - availableToolNames?: readonly string[], - ): Promise | AgentSystem; - }; - latestConversationUserText?: (signal: AbortSignal) => Promise; - publishParentRunEvents?: NonNullable; - toolExposureCheckpoint?: { - initial?: CreationOptions["serverResolvedToolExposureCheckpoint"]; - persist: NonNullable; - }; - providerReplayCheckpoint?: { - initial?: CreationOptions["serverResolvedProviderReplayCheckpoints"]; - persist: NonNullable; - }; - /** Own partial facade setup and prepared runtime resources, not the channel/allocation. */ - cleanup(): Promise; -} interface Options { binding: ExecutorBinding; source: ExecutorDiscoverySource; @@ -207,667 +33,48 @@ interface Options { facades: ExecutorRuntimeFacades; } -function refuse(code: ConstructorParameters[0]): never { - throw new ExecutorRuntimePreparationError(code); -} -function snapshotGrant( - grant: ExecutorRuntimePreparationGrant | undefined, -): ExecutorRuntimeGrantData | undefined { - if (!grant) return undefined; - if (!(grant.models instanceof Map)) return refuse("EXECUTOR_RUNTIME_INVALID_INPUT"); - const parsed = parseRuntimePreparationData(getExecutorRuntimeGrantDataSchema(), { - ...grant, - models: [...grant.models].map(([id, policy]) => ({ id, ...policy })), - }); - if ( - parsed.models.some((model) => - !model.id.startsWith(VERYFRONT_CLOUD_MODEL_PREFIX) || - model.id.length === VERYFRONT_CLOUD_MODEL_PREFIX.length - ) || !parsed.models.some((model) => model.id === parsed.defaultModelId) || - createPrivateSet(parsed.models.map((model) => model.id)).size !== parsed.models.length - ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - objectSetPrototypeOf(parsed, null); - objectSetPrototypeOf(parsed.execution, null); - return parsed; -} - -function snapshotCheckpointFacade( - facade: { initial?: I; persist: (checkpoint: C) => void | Promise } | undefined, -): { initial?: I; persist: (checkpoint: C) => void | Promise } | undefined { - if (!facade) return undefined; - const descriptor = objectGetOwnPropertyDescriptor(facade, "initial"); - const initial = descriptor && hasOwn(descriptor, "value") ? descriptor.value as I : undefined; - const persist = snapshotFacadeMethod(facade, "persist"); - const snapshot = { - initial, - persist: typeof persist === "function" - ? (checkpoint: C) => - chain( - resolvePrivatePromise(), - () => apply(persist, facade, [checkpoint]) as void | Promise, - ) - : persist, - }; - objectSetPrototypeOf(snapshot, null); - return snapshot; -} - -function snapshotFacadeMethod(facade: T, key: K): T[K] { - let current: object | null = facade; - for (let depth = 0; current !== null && current !== objectPrototype && depth < 128; depth++) { - const descriptor = objectGetOwnPropertyDescriptor(current, key); - if (descriptor) { - const method = hasOwn(descriptor, "value") ? descriptor.value : undefined; - return (typeof method === "function" - ? (...args: unknown[]) => apply(method, facade, args) - : undefined) as T[K]; - } - current = objectGetPrototypeOf(current); - } - return undefined as T[K]; -} - -function snapshotSteeringFacade( - facade: ExecutorRuntimeFacades["projectSteering"], -): ExecutorRuntimeFacades["projectSteering"] { - if (!facade) return undefined; - const snapshot = { - prepare: snapshotFacadeMethod(facade, "prepare"), - refresh: snapshotFacadeMethod(facade, "refresh"), - }; - objectSetPrototypeOf(snapshot, null); - return snapshot; -} -function sameBinding(left: ExecutorBinding, right: ExecutorBinding) { - return left.allocationId === right.allocationId && left.generation === right.generation && - left.invocationId === right.invocationId; -} -function intersectNames( - granted: readonly string[], - source: true | readonly string[] | undefined, - requested: readonly string[] | undefined, - denied: readonly string[] = [], -) { - return filter( - granted, - (name) => - (source === undefined || source === true || includes(source, name)) && - (requested === undefined || includes(requested, name)) && !includes(denied, name), - ); -} - -/** - * One allocation's fixed-project preparation/stream dispatcher. Metadata never - * installs execution authority; project navigation requires separate broker support. - */ +/** Preserve executor-local discovery and preparation without duplicating runtime policy. */ export function createExecutorRuntimePreparation(input: Options) { - const grant = snapshotGrant(input.grant); - const modelGrants = new Map(grant?.models.map((model) => [model.id, model]) ?? []); - const binding = parseRuntimePreparationData(getExecutorBindingSchema(), input.binding); - const source = parseRuntimePreparationData(getExecutorDiscoverySourceSchema(), input.source); - objectSetPrototypeOf(binding, null); - objectSetPrototypeOf(source, null); - const installedModelResolver = input.facades.resolveModelRuntime; - const facades: ExecutorRuntimeFacades = { - resolveModelRuntime: snapshotFacadeMethod(input.facades, "resolveModelRuntime"), - cleanup: snapshotFacadeMethod(input.facades, "cleanup"), - projectSteering: snapshotSteeringFacade(input.facades.projectSteering), - latestConversationUserText: snapshotFacadeMethod(input.facades, "latestConversationUserText"), - publishParentRunEvents: snapshotFacadeMethod(input.facades, "publishParentRunEvents"), - hostTools: new Map(input.facades.hostTools), - remoteToolSources: new Map(input.facades.remoteToolSources), - toolExposureCheckpoint: snapshotCheckpointFacade(input.facades.toolExposureCheckpoint), - providerReplayCheckpoint: snapshotCheckpointFacade(input.facades.providerReplayCheckpoint), - }; - objectSetPrototypeOf(facades, null); - const lifetime = new AbortController(); - let preparation: Promise | undefined; - let preparedOperations: ReadonlyMap | undefined; - let closing: Promise | undefined; - let resourcesStarted = false; - let cleanup: Promise | undefined; - let startup: Promise> | undefined; - let producerCompletion: Promise | undefined; - let streamSignal = lifetime.signal; - const settled = createPrivateDeferred(); - void chain(settled.promise, () => {}, () => {}); - const assertActive = () => { - if (lifetime.signal.aborted || input.discovery.signal.aborted) { - refuse("EXECUTOR_RUNTIME_CLOSED"); - } - }; - const release = () => { - cleanup ??= chain(resolvePrivatePromise(), async () => { - // Startup can still reserve producer work. Join both before releasing - // facades, without joining the stream handler that calls this cleanup. - if (startup) await chain(startup, () => {}, () => {}); - if (producerCompletion) await observePrivatePromise(producerCompletion); - if (resourcesStarted) await observePrivatePromise(facades.cleanup()); - }); - return cleanup; - }; - function close(): Promise { - if (closing) return closing; - closing = chain(resolvePrivatePromise(), async () => { - if (preparation) await chain(preparation, () => {}, () => {}); - let failed = false; - try { - await release(); - } catch { - failed = true; - } - try { - await observePrivatePromise(input.discovery.close()); - } catch { - failed = true; - } - preparedOperations = undefined; - if (failed) refuse("EXECUTOR_RUNTIME_CLEANUP_FAILED"); - }); - void chain(closing, settled.resolve, settled.reject); - apply(abortController, lifetime, []); - apply(removeEventListener, input.discovery.signal, ["abort", onDiscoveryAbort]); - return closing; - } - const onDiscoveryAbort = () => { - void chain(close(), () => {}, () => {}); - }; - apply(addEventListener, input.discovery.signal, ["abort", onDiscoveryAbort, { once: true }]); - if (input.discovery.signal.aborted) onDiscoveryAbort(); - - function requireFacades( - definition: RuntimeAgentMarkdownDefinition, - effective: ExecutorRuntimeGrantData, - ) { - if ( - typeof facades.resolveModelRuntime !== "function" || typeof facades.cleanup !== "function" - ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - for (let index = 0; index < effective.hostToolFacadeIds.length; index++) { - const id = effective.hostToolFacadeIds[index]; - if (id === undefined) continue; - if (!privateMapHas(facades.hostTools, id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - for (let index = 0; index < effective.remoteToolSourceIds.length; index++) { - const id = effective.remoteToolSourceIds[index]; - if (id === undefined) continue; - if (!privateMapHas(facades.remoteToolSources, id)) { - refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - } - const configuredServers = definition.mcpServers ?? []; - for (let index = 0; index < configuredServers.length; index++) { - const server = configuredServers[index]; - if (server === undefined) continue; - if (!includes(effective.remoteToolSourceIds, server.id ?? server.kind)) { - refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - } - if ( - (effective.execution.projectId !== null || - includes(effective.requiredCapabilities ?? [], "project-steering")) && - (typeof facades.projectSteering?.prepare !== "function" || - typeof facades.projectSteering?.refresh !== "function") - ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - if ( - includes(effective.requiredCapabilities ?? [], "conversation-user-text") && - typeof facades.latestConversationUserText !== "function" - ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - if (effective.execution.kind === "canonical") { - if ( - typeof facades.publishParentRunEvents !== "function" || - typeof facades.toolExposureCheckpoint?.persist !== "function" - ) { - refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - if ( - effective.execution.providerReplay === "required" && - typeof facades.providerReplayCheckpoint?.persist !== "function" - ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - } - - async function prepare( - request: ExecutorRuntimePrepareRequest, - context: ExecutorOperationContext, - ): Promise { - try { - assertActive(); - if (!grant || grant.agentId !== request.agentId || !sameBinding(binding, context.binding)) { - refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - } - const operation = privateMapGet(input.discovery.operations, "agent.describe"); - if (operation?.mode !== "unary") refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - const described = parseDiscoveryData( - getExecutorAgentDescribeResultSchema(), - await chain( - resolvePrivatePromise(), - () => operation.handle({ agentId: request.agentId }, context), - ), - true, - ); - if ( - !described.ok || described.value.definition.id !== grant.agentId || - verifyHostedRuntimeSourceBinding(source, described.value.source) - ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - const definition = described.value.definition; - const modelId = request.modelId ?? grant.defaultModelId; - const modelGrant = privateMapGet(modelGrants, modelId); - if ( - !modelGrant || (request.maxSteps !== undefined && request.maxSteps > grant.maxSteps) || - (request.maxOutputTokens !== undefined && - request.maxOutputTokens > modelGrant.maxOutputTokens) - ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - const thinking = request.thinking ?? definition.thinking ?? - resolveVeryfrontCloudModelThinking(modelId); - let availableOutputTokens = modelGrant.maxOutputTokens; - const modelProvider = tryGetVeryfrontCloudProviderFromModelId(modelId); - if (modelProvider === "anthropic") { - try { - const effectiveThinking = thinking ?? resolveVeryfrontCloudModelThinking(modelId); - const model = { id: modelId, modelId, provider: modelProvider }; - const options = { - reasoning: resolveVeryfrontCloudReasoningOption(modelId, effectiveThinking), - providerOptions: resolveVeryfrontCloudThinkingProviderOptions( - modelId, - effectiveThinking, - ), - }; - objectSetPrototypeOf(model, null); - objectSetPrototypeOf(options, null); - availableOutputTokens -= getExecutorModelAdditiveReasoningTokens({ model, options }); - } catch { - refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + const discovery = input.discovery; + let runtime: ProjectAgentRuntimeDiscovery | undefined; + const owner = createRuntimePreparationCore({ + binding: input.binding, + source: input.source, + grant: input.grant, + facades: input.facades, + project: { + signal: discovery.signal, + async prepare(agentId, context) { + const operation = apply(mapGet, discovery.operations, ["agent.describe"]); + if (operation?.mode !== "unary") { + throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); } - } - const maxOutputTokens = request.maxOutputTokens ?? availableOutputTokens; - if ( - !numberIsSafeInteger(maxOutputTokens) || maxOutputTokens <= 0 || - maxOutputTokens > availableOutputTokens - ) { - refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - } - requireFacades(definition, grant); - const runtime = input.discovery.getRuntime(); - // Enroll only after agent.describe returns: a failed discovery operation - // can await discovery.close(). No remaining preparation work awaits it. - input.discovery.retainRuntimeTask(preparation!); - let localTools: HostToolSet = Object.fromEntries( - filter( - [...runtime.tools], - ([id, value]) => - !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId: definition.id }), - ), - ); - for (let index = 0; index < grant.hostToolFacadeIds.length; index++) { - const id = grant.hostToolFacadeIds[index]; - if (id === undefined) continue; - // Object spread creates own data properties without invoking mutable - // Object.assign or inherited setters with private facade values. - localTools = { ...localTools, ...privateMapGet(facades.hostTools, id) }; - } - const normalizeToolNames = (names: readonly string[]) => [ - ...resolveOwnerScopedToolNames({ - toolNames: names, - agentId: definition.id, - localTools, - })!, - ]; - const deniedToolSet = createPrivateSet(definition.deniedTools ?? []); - const normalizedDenials = normalizeToolNames(definition.deniedTools ?? []); - for (let index = 0; index < normalizedDenials.length; index++) { - const name = normalizedDenials[index]; - if (name !== undefined) deniedToolSet.add(name); - } - const deniedToolNames = [...deniedToolSet]; - const sourceToolNames = resolveHostedRuntimeAllowedTools({ - configuredTools: definition.tools, - configuredDeniedTools: definition.deniedTools, - configuredDelegates: definition.delegates, - configuredSkills: definition.skills, - requestedTools: undefined, - }); - let allowedToolNames = intersectNames( - normalizeToolNames(grant.allowedToolNames), - arrayIsArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, - request.allowedToolNames === undefined - ? undefined - : normalizeToolNames(request.allowedToolNames), - deniedToolNames, - ); - if (includes(allowedToolNames, "studio_open_project")) { - refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - const providerToolNames = intersectNames( - modelGrant.providerToolNames, - resolveHostedRuntimeAllowedProviderTools({ - configuredProviderTools: definition.providerTools, - requestedTools: undefined, - }), - request.providerToolNames, - definition.deniedTools, - ); - const resolveModelRuntime: AgentModelRuntimeResolver = (id) => { - assertActive(); - if (!privateMapHas(modelGrants, id)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - return facades.resolveModelRuntime(id) ?? refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - }; - registerModelRuntimeResolverRevoker( - resolveModelRuntime, - () => revokeModelRuntimeResolver(installedModelResolver), - ); - // The first facade call can reserve resources before throwing. - resourcesStarted = true; - resolveModelRuntime(modelId); - const execution = grant.execution; - const steeringResult = facades.projectSteering - ? await observePrivatePromise(facades.projectSteering.prepare({ - definition, - projectId: execution.projectId, - branchId: execution.branchId, - signal: context.signal, - })) - : undefined; - const steering = steeringResult === undefined ? undefined : parseRuntimePreparationData( - getExecutorRuntimeSteeringSchema(), - steeringResult, - ); - assertActive(); - if (steering && steering.agent.id !== definition.id) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - const skills = resolveRuntimeSkillSelectorForAgent({ - skills: steering?.initialSkills ?? [], - agentId: definition.id, - selector: definition.skills === false ? [] : definition.skills, - }); - if (sourceToolNames !== undefined || request.allowedToolNames === undefined) { - const effectiveSourceTools = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: normalizeToolNames(sourceToolNames ?? allowedToolNames), - localToolNames: filter( - normalizeToolNames(grant.allowedToolNames), - (name) => hasOwn(localTools, name), - ), - availableSkillIds: skills.allowedSkillIds, - configDerivedSelector: request.allowedToolNames === undefined && - !(definition.tools === true && (definition.deniedTools?.length ?? 0) > 0), - }); - allowedToolNames = intersectNames( - normalizeToolNames(grant.allowedToolNames), - effectiveSourceTools === null ? undefined : [...effectiveSourceTools], - request.allowedToolNames === undefined - ? undefined - : normalizeToolNames(request.allowedToolNames), - deniedToolNames, - ); - } - const taskContext = { - ...execution, - steeringRevision: 0, - agentId: definition.id, - model: modelId, - availableSkillIds: skills.allowedSkillIds, - ...(execution.kind === "canonical" - ? { parentRunId: execution.runId, parentMessageId: execution.messageId } - : {}), - }; - objectSetPrototypeOf(taskContext, null); - const options: PreparedHostedRuntimeAgentOptions["options"] = { - ...execution, - agentId: definition.id, - model: modelId, - instructions: request.instructions ?? - (steering - ? buildInteractiveVeryfrontCloudRuntimeInstructions({ - agentConfig: definition, - projectId: execution.projectId, - branchId: execution.branchId, - instructions: steering.initialProjectInstructions ?? "", - skills: includes(allowedToolNames, "load_skill") ? skills.definitions : [], - environmentContext: steering.environmentContext, - availableToolNames: allowedToolNames, - }) - : definition.system ?? definition.instructions), - temperature: request.temperature ?? definition.temperature, - thinking, - maxSteps: mathMin( - request.maxSteps ?? grant.maxSteps, - definition.maxSteps ?? grant.maxSteps, - grant.maxSteps, - ), - maxOutputTokens, - allowedTools: allowedToolNames, - allowedProviderTools: providerToolNames, - availableSkillIds: skills.allowedSkillIds, - skillSelectorPolicy: skills.policy, - skillSourcePaths: skills.skillSourcePaths, - ...(steering - ? { - liveProjectSteering: { - ...steering, - agent: definition, - initialSkills: skills.definitions, - }, - } - : {}), - ...(execution.kind === "canonical" - ? { - parentRunId: execution.runId, - parentMessageId: execution.messageId, - publishParentRunEvents: facades.publishParentRunEvents, - persistToolExposureCheckpoint: facades.toolExposureCheckpoint!.persist, - serverResolvedToolExposureCheckpoint: facades.toolExposureCheckpoint!.initial, - requireToolExposureCheckpointPersistence: true, - ...(execution.providerReplay === "required" - ? { - persistProviderReplayCheckpoint: facades.providerReplayCheckpoint!.persist, - serverResolvedProviderReplayCheckpoints: facades.providerReplayCheckpoint!.initial, - providerReplayCheckpointMessageId: execution.messageId, - requireProviderReplayCheckpointPersistence: true, - } - : {}), - } - : {}), - }; - objectSetPrototypeOf(options, null); - const remoteToolSources: RemoteToolSource[] = []; - for (let index = 0; index < grant.remoteToolSourceIds.length; index++) { - const id = grant.remoteToolSourceIds[index]; - if (id === undefined) continue; - let remoteToolSource = privateMapGet(facades.remoteToolSources, id)!; - const servers = filter( - definition.mcpServers ?? [], - (server) => (server.id ?? server.kind) === id, - ); - for (let serverIndex = 0; serverIndex < servers.length; serverIndex++) { - const server = servers[serverIndex]; - if (server !== undefined) { - remoteToolSource = wrapRemoteToolSourceWithMcpPolicy( - remoteToolSource, - server.toolPolicy, - ); - } - } - defineOwnDataProperty( - remoteToolSources, - remoteToolSources.length, - remoteToolSource, - { enumerable: true, configurable: true, writable: true }, - ); - } - const facadeAllowedToolSet = createPrivateSet(); - for (let index = 0; index < allowedToolNames.length; index++) { - const name = allowedToolNames[index]; - if (name !== undefined) facadeAllowedToolSet.add(name); - } - for (let index = 0; index < providerToolNames.length; index++) { - const name = providerToolNames[index]; - if (name !== undefined) facadeAllowedToolSet.add(name); - } - const facadeAllowedToolNames = [...facadeAllowedToolSet]; - const assemblyInput: Parameters[0] = { - signal: context.signal, - taskContext, - instructions: options.instructions, - localTools: selectAllowedHostTools(localTools, facadeAllowedToolNames), - sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - hostToolPolicy: { allow: facadeAllowedToolNames }, - allowedToolNames, - deniedToolNames, - allowedProviderToolNames: providerToolNames, - sourceProviderToolNames: definition.providerTools, - prepareRemoteToolInput: ({ toolName, toolInput }) => - applyDefaultResearchArtifactPath(toolName, toolInput, taskContext), - shouldRetryWithRemoteTool: ({ toolName, toolInput, error }) => - shouldRetryCreateResearchArtifactAsUpdate({ - toolName, - toolInput, - taskContext, - error, - }), - remoteToolSources, - onSteeringMutation: (mutation) => { - if (mutation.instructionsChanged || mutation.skillsChanged) { - incrementSteeringRevision(taskContext); - } - }, - loadLatestConversationUserText: facades.latestConversationUserText, - }; - objectSetPrototypeOf(assemblyInput, null); - const toolAssembly = await observePrivatePromise( - prepareFacadedHostedChatRuntimeToolAssembly(assemblyInput), - ); - assertActive(); - for (const name of toolAssembly.normalizedAllowedToolNames ?? []) { - if (!includes(toolAssembly.authorizedToolNames, name)) { - refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); - } - } - const scopedAssembly = { - ...toolAssembly, - runtimeTools: scopeHostedRuntimeToolResults(toolAssembly.runtimeTools), - }; - objectSetPrototypeOf(scopedAssembly, null); - const runtimeInput: PreparedHostedRuntimeAgentOptions = { - options, - taskContext, - toolAssembly: scopedAssembly, - modelId, - sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - refreshSystem: facades.projectSteering - ? () => facades.projectSteering!.refresh(streamSignal, toolAssembly.modelVisibleToolNames) - : undefined, - }; - const runtimeOptions: NonNullable[1]> = { - resolveModelRuntime, - preserveToolCatalog: true, - onStreamCompletion: (completion) => { - producerCompletion = completion; - input.discovery.retainRuntimeTask(completion); - }, - }; - objectSetPrototypeOf(runtimeInput, null); - objectSetPrototypeOf(runtimeOptions, null); - const runtimeAgent = runWithProjectAgentRuntime( - runtime, - () => createPreparedHostedRuntimeAgent(runtimeInput, runtimeOptions), - ); - assertActive(); - const preparedRuntimeHandle = crypto.randomUUID(); - preparedOperations = createExecutorAgentOperations({ - preparedRuntimeHandle, - startStream: (streamInput) => { - streamSignal = streamInput.abortSignal; - startup = chain(resolvePrivatePromise(), () => { - assertActive(); - const streamOptions: Parameters[0] = { - runtimeAgent, - sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - agentId: definition.id, - projectId: execution.projectId ?? undefined, - projectSlug: execution.projectSlug, - ...(execution.kind === "canonical" - ? { runId: execution.runId, conversationId: execution.conversationId } - : {}), - maxOutputTokens: options.maxOutputTokens, - }; - objectSetPrototypeOf(streamOptions, null); - return createHostedChatRuntimeDataStream(streamOptions, streamInput); - }); - input.discovery.retainRuntimeTask(startup); - return startup; - }, - cleanup: release, - }); - return executorAgentJson({ - ok: true, - value: { preparedRuntimeHandle, runtimeKind: "framework", modelId }, - }, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); - } catch (error) { - try { - await release(); - } catch { - return { ok: false, code: "EXECUTOR_RUNTIME_CLEANUP_FAILED" }; - } - const knownFailure = executorAgentFailureCode(error, "EXECUTOR_AGENT_SETUP_FAILED"); - const code = lifetime.signal.aborted - ? "ABORTED" - : error instanceof ExecutorRuntimePreparationError - ? error.code - : knownFailure === "EXECUTOR_AGENT_SETUP_FAILED" - ? "EXECUTOR_RUNTIME_PREPARATION_FAILED" - : knownFailure; - return { ok: false, code }; - } - } - - const operations = new Map(input.discovery.operations); - operations.set("runtime.prepare", { - mode: "unary", - async handle(value, context) { - try { - assertActive(); - if (preparation) refuse("EXECUTOR_RUNTIME_ALREADY_PREPARED"); - const request = parseRuntimePreparationData( - getExecutorRuntimePrepareRequestSchema(), - value, + const description = await chainPrivatePromise( + resolvePrivatePromise(), + () => operation.handle({ agentId }, context), ); - executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); - const cancel = () => { - void chain(close(), () => {}, () => {}); - }; - apply(addEventListener, context.signal, ["abort", cancel, { once: true }]); - preparation = prepare(request, { - ...context, - signal: combineSignals(context.signal, lifetime.signal), - }); - if (context.signal.aborted) cancel(); - try { - return await observePrivatePromise(preparation); - } finally { - apply(removeEventListener, context.signal, ["abort", cancel]); - } - } catch (error) { + runtime = discovery.getRuntime(); return { - ok: false, - code: error instanceof ExecutorRuntimePreparationError - ? error.code - : "EXECUTOR_RUNTIME_INVALID_INPUT", + __proto__: null, + description, + localTools: Object.fromEntries(filterPrivateArray( + [...runtime.tools], + ([id, value]) => + !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId }), + )), + sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, }; - } - }, - }); - operations.set("agent.stream", { - mode: "stream", - async *handle(value, context) { - assertActive(); - if (!sameBinding(binding, context.binding)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); - const operation = preparedOperations === undefined - ? undefined - : privateMapGet(preparedOperations, "agent.stream"); - if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); - yield* getPrivateAsyncIterator(operation.handle(value, { - ...context, - signal: combineSignals(context.signal, lifetime.signal), - })); + }, + instantiate: (options, runtimeOptions) => { + if (!runtime) throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_NOT_PREPARED"); + return runWithProjectAgentRuntime( + runtime, + () => createPreparedHostedRuntimeAgent(options, runtimeOptions), + ); + }, + retainTask: (task) => discovery.retainRuntimeTask(task), + close: () => discovery.close(), }, }); - return { operations, close, settled: settled.promise, signal: lifetime.signal }; + return { ...owner, operations: new Map([...discovery.operations, ...owner.operations]) }; } diff --git a/src/agent/hosted/runtime-preparation-core.ts b/src/agent/hosted/runtime-preparation-core.ts new file mode 100644 index 0000000000..a695c6f9dc --- /dev/null +++ b/src/agent/hosted/runtime-preparation-core.ts @@ -0,0 +1,889 @@ +import { getPrivateAsyncIterator } from "#veryfront/security/private-iterator.ts"; +import { mapPrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { + chainPrivatePromise as chain, + createPrivateDeferred, + observePrivatePromise, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { + resolveVeryfrontCloudModelThinking, + resolveVeryfrontCloudReasoningOption, + resolveVeryfrontCloudThinkingProviderOptions, + tryGetVeryfrontCloudProviderFromModelId, + VERYFRONT_CLOUD_MODEL_PREFIX, +} from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; +import { getExecutorModelAdditiveReasoningTokens } from "#veryfront/agent/hosted/executor-model-grant.ts"; +import type { HostToolSet, RemoteToolSource } from "#veryfront/tool"; +import type { AgentSystem } from "#veryfront/agent/types.ts"; +import { + type AgentModelRuntimeResolver, + registerModelRuntimeResolverRevoker, + revokeModelRuntimeResolver, +} from "#veryfront/agent/runtime/model-transport.ts"; +import { wrapRemoteToolSourceWithMcpPolicy } from "#veryfront/agent/mcp-tool-policy.ts"; +import type { RuntimeAgentMarkdownDefinition } from "#veryfront/agent/runtime/agent-definition.ts"; +import { + type ExecutorBinding, + getExecutorBindingSchema, +} from "#veryfront/agent/executor/protocol.ts"; +import type { + ExecutorOperation, + ExecutorOperationContext, +} from "#veryfront/agent/executor/channel.ts"; +import { + type ExecutorDiscoverySource, + getExecutorAgentDescribeResultSchema, + getExecutorDiscoverySourceSchema, + parseDiscoveryData, +} from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { verifyHostedRuntimeSourceBinding } from "#veryfront/agent/hosted/runtime-source-binding.ts"; +import { + resolveHostedRuntimeAllowedProviderTools, + resolveHostedRuntimeAllowedTools, +} from "#veryfront/agent/hosted/runtime-request-config.ts"; +import { resolveHostedRuntimeAllowedToolNames } from "#veryfront/agent/hosted/runtime-essential-tools.ts"; +import { + executorAgentFailureCode, + executorAgentJson, +} from "#veryfront/agent/hosted/executor-agent-schema.ts"; +import { createExecutorAgentOperations } from "#veryfront/agent/hosted/executor-agent-bridge.ts"; +import { createHostedChatRuntimeDataStream } from "#veryfront/agent/hosted/chat-runtime-agent-adapter.ts"; +import { + createPreparedHostedRuntimeAgent, + incrementSteeringRevision, + type PreparedHostedRuntimeAgentOptions, + scopeHostedRuntimeToolResults, +} from "#veryfront/agent/hosted/default-chat-runtime.ts"; +import { + prepareFacadedHostedChatRuntimeToolAssembly, + resolveOwnerScopedToolNames, +} from "#veryfront/agent/hosted/chat-runtime-tool-assembly.ts"; +import type { + HostedChatRuntimeCreationOptions, + HostedChatRuntimeProjectSteering, +} from "#veryfront/agent/hosted/chat-runtime-contract.ts"; +import type { RuntimeAgentThinkingConfig } from "#veryfront/agent/runtime/agent-definition.ts"; +import { resolveRuntimeSkillSelectorForAgent } from "#veryfront/agent/runtime/skill-metadata.ts"; +import { + applyDefaultResearchArtifactPath, + shouldRetryCreateResearchArtifactAsUpdate, +} from "#veryfront/agent/artifacts/default-research-artifact-support.ts"; +import { buildInteractiveVeryfrontCloudRuntimeInstructions } from "#veryfront/agent/hosted/cloud-runtime-system-messages.ts"; +import { + type ExecutorRuntimeGrantData, + ExecutorRuntimePreparationError, + type ExecutorRuntimePrepareRequest, + getExecutorRuntimeGrantDataSchema, + getExecutorRuntimePrepareRequestSchema, + getExecutorRuntimeSteeringSchema, + parseRuntimePreparationData, +} from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; + +const apply = Reflect.apply; +const mapGet = Map.prototype.get; +const mapHas = Map.prototype.has; +const hasOwn = Object.hasOwn; +const objectSetPrototypeOf = Object.setPrototypeOf; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectGetPrototypeOf = Object.getPrototypeOf; +const objectPrototype = Object.prototype; +const objectEntries = Object.entries; +const arrayIncludes = Array.prototype.includes; +const arrayIsArray = Array.isArray; +const abortController = AbortController.prototype.abort; +const abortSignalAny = AbortSignal.any; +const AbortSignalConstructor = AbortSignal; +const mathMin = Math.min; +const numberIsSafeInteger = Number.isSafeInteger; +const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; +const iteratorSymbol = Symbol.iterator; + +function combineSignals(...signals: AbortSignal[]): AbortSignal { + const inputs = createPrivateSet(signals); + defineOwnDataProperty(signals, iteratorSymbol, () => inputs.values()); + return apply(abortSignalAny, AbortSignalConstructor, [signals]) as AbortSignal; +} + +function filter(values: readonly T[], predicate: (value: T) => boolean): T[] { + const filtered: T[] = []; + for (let index = 0; index < values.length; index++) { + const value = values[index] as T; + if (!predicate(value)) continue; + defineOwnDataProperty( + filtered, + filtered.length, + value, + { enumerable: true, configurable: true, writable: true }, + ); + } + return filtered; +} +function includes(values: readonly T[], value: T): boolean { + return apply(arrayIncludes, values, [value]) as boolean; +} + +function privateMapGet(map: ReadonlyMap, key: K): V | undefined { + return apply(mapGet, map, [key]) as V | undefined; +} +function privateMapHas(map: ReadonlyMap, key: K): boolean { + return apply(mapHas, map, [key]) as boolean; +} + +function selectAllowedHostTools( + tools: HostToolSet, + allowedNames: readonly string[], +): HostToolSet { + const allowed = createPrivateSet(allowedNames); + const entries = apply(objectEntries, Object, [tools]) as Array< + [string, HostToolSet[string]] + >; + const selected: HostToolSet = {}; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (entry === undefined || !allowed.has(entry[0])) continue; + defineOwnDataProperty( + selected, + entry[0], + entry[1], + { enumerable: true, configurable: true, writable: true }, + ); + } + return selected; +} + +type CreationOptions = HostedChatRuntimeCreationOptions< + RuntimeAgentMarkdownDefinition, + RuntimeAgentThinkingConfig +>; +export type ExecutorRuntimePreparationGrant = Omit & { + /** Preparation selections only. Invocation-wide call accounting is enforced by the broker. */ + models: ReadonlyMap; +}; +export interface ExecutorRuntimeFacades { + /** Must be the invocation's granted model proxy; missing models throw instead of using provider defaults. */ + resolveModelRuntime: AgentModelRuntimeResolver; + hostTools: ReadonlyMap; + remoteToolSources: ReadonlyMap; + projectSteering?: { + prepare( + input: { + definition: RuntimeAgentMarkdownDefinition; + projectId: string | null; + branchId?: string | null; + signal: AbortSignal; + }, + ): Promise>; + refresh( + signal: AbortSignal, + availableToolNames?: readonly string[], + ): Promise | AgentSystem; + }; + latestConversationUserText?: (signal: AbortSignal) => Promise; + publishParentRunEvents?: NonNullable; + toolExposureCheckpoint?: { + initial?: CreationOptions["serverResolvedToolExposureCheckpoint"]; + persist: NonNullable; + }; + providerReplayCheckpoint?: { + initial?: CreationOptions["serverResolvedProviderReplayCheckpoints"]; + persist: NonNullable; + }; + /** Own partial facade setup and prepared runtime resources, not the channel/allocation. */ + cleanup(): Promise; +} +export interface RuntimePreparationSourceSnapshot { + description: unknown; + localTools: HostToolSet; + toolAliases?: ReadonlyMap; + sourceIntegrationPolicy: PreparedHostedRuntimeAgentOptions["sourceIntegrationPolicy"]; +} +/** Framework-owned source/lifetime adapter, never received from the execution protocol. */ +export interface RuntimePreparationSource { + readonly signal: AbortSignal; + prepare( + agentId: string, + context: ExecutorOperationContext, + ): Promise; + instantiate: typeof createPreparedHostedRuntimeAgent; + retainTask(task: Promise): void; + close(): Promise; +} +export interface RuntimePreparationCoreOptions { + binding: ExecutorBinding; + source: ExecutorDiscoverySource; + project: RuntimePreparationSource; + grant?: ExecutorRuntimePreparationGrant; + facades: ExecutorRuntimeFacades; + projectTools?: RemoteToolSource; +} + +function refuse(code: ConstructorParameters[0]): never { + throw new ExecutorRuntimePreparationError(code); +} +function snapshotGrant( + grant: ExecutorRuntimePreparationGrant | undefined, +): ExecutorRuntimeGrantData | undefined { + if (!grant) return undefined; + if (!(grant.models instanceof Map)) return refuse("EXECUTOR_RUNTIME_INVALID_INPUT"); + const parsed = parseRuntimePreparationData(getExecutorRuntimeGrantDataSchema(), { + ...grant, + models: [...grant.models].map(([id, policy]) => ({ id, ...policy })), + }); + if ( + parsed.models.some((model) => + !model.id.startsWith(VERYFRONT_CLOUD_MODEL_PREFIX) || + model.id.length === VERYFRONT_CLOUD_MODEL_PREFIX.length + ) || !parsed.models.some((model) => model.id === parsed.defaultModelId) || + createPrivateSet(parsed.models.map((model) => model.id)).size !== parsed.models.length + ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + objectSetPrototypeOf(parsed, null); + objectSetPrototypeOf(parsed.execution, null); + return parsed; +} + +function snapshotCheckpointFacade( + facade: { initial?: I; persist: (checkpoint: C) => void | Promise } | undefined, +): { initial?: I; persist: (checkpoint: C) => void | Promise } | undefined { + if (!facade) return undefined; + const descriptor = objectGetOwnPropertyDescriptor(facade, "initial"); + const initial = descriptor && hasOwn(descriptor, "value") ? descriptor.value as I : undefined; + const persist = snapshotFacadeMethod(facade, "persist"); + const snapshot = { + initial, + persist: typeof persist === "function" + ? (checkpoint: C) => + chain( + resolvePrivatePromise(), + () => apply(persist, facade, [checkpoint]) as void | Promise, + ) + : persist, + }; + objectSetPrototypeOf(snapshot, null); + return snapshot; +} + +function snapshotFacadeMethod(facade: T, key: K): T[K] { + let current: object | null = facade; + for (let depth = 0; current !== null && current !== objectPrototype && depth < 128; depth++) { + const descriptor = objectGetOwnPropertyDescriptor(current, key); + if (descriptor) { + const method = hasOwn(descriptor, "value") ? descriptor.value : undefined; + return (typeof method === "function" + ? (...args: unknown[]) => apply(method, facade, args) + : undefined) as T[K]; + } + current = objectGetPrototypeOf(current); + } + return undefined as T[K]; +} + +function snapshotSteeringFacade( + facade: ExecutorRuntimeFacades["projectSteering"], +): ExecutorRuntimeFacades["projectSteering"] { + if (!facade) return undefined; + const snapshot = { + prepare: snapshotFacadeMethod(facade, "prepare"), + refresh: snapshotFacadeMethod(facade, "refresh"), + }; + objectSetPrototypeOf(snapshot, null); + return snapshot; +} +function sameBinding(left: ExecutorBinding, right: ExecutorBinding) { + return left.allocationId === right.allocationId && left.generation === right.generation && + left.invocationId === right.invocationId; +} +function intersectNames( + granted: readonly string[], + source: true | readonly string[] | undefined, + requested: readonly string[] | undefined, + denied: readonly string[] = [], +) { + return filter( + granted, + (name) => + (source === undefined || source === true || includes(source, name)) && + (requested === undefined || includes(requested, name)) && !includes(denied, name), + ); +} + +/** + * One allocation's fixed-project preparation/stream dispatcher. Metadata never + * installs execution authority; project navigation requires separate broker support. + */ +export function createRuntimePreparationCore(input: RuntimePreparationCoreOptions) { + const grant = snapshotGrant(input.grant); + const modelGrants = new Map(grant?.models.map((model) => [model.id, model]) ?? []); + const binding = parseRuntimePreparationData(getExecutorBindingSchema(), input.binding); + const source = parseRuntimePreparationData(getExecutorDiscoverySourceSchema(), input.source); + objectSetPrototypeOf(binding, null); + objectSetPrototypeOf(source, null); + const installedModelResolver = input.facades.resolveModelRuntime; + const facades: ExecutorRuntimeFacades = { + resolveModelRuntime: snapshotFacadeMethod(input.facades, "resolveModelRuntime"), + cleanup: snapshotFacadeMethod(input.facades, "cleanup"), + projectSteering: snapshotSteeringFacade(input.facades.projectSteering), + latestConversationUserText: snapshotFacadeMethod(input.facades, "latestConversationUserText"), + publishParentRunEvents: snapshotFacadeMethod(input.facades, "publishParentRunEvents"), + hostTools: new Map(input.facades.hostTools), + remoteToolSources: new Map(input.facades.remoteToolSources), + toolExposureCheckpoint: snapshotCheckpointFacade(input.facades.toolExposureCheckpoint), + providerReplayCheckpoint: snapshotCheckpointFacade(input.facades.providerReplayCheckpoint), + }; + if (input.projectTools) { + facades.remoteToolSources = new Map(facades.remoteToolSources).set( + input.projectTools.id, + input.projectTools, + ); + } + objectSetPrototypeOf(facades, null); + const lifetime = new AbortController(); + let preparation: Promise | undefined; + let preparedOperations: ReadonlyMap | undefined; + let closing: Promise | undefined; + let resourcesStarted = false; + let cleanup: Promise | undefined; + let startup: Promise> | undefined; + let producerCompletion: Promise | undefined; + let streamSignal = lifetime.signal; + const settled = createPrivateDeferred(); + void chain(settled.promise, () => {}, () => {}); + const assertActive = () => { + if (lifetime.signal.aborted || input.project.signal.aborted) { + refuse("EXECUTOR_RUNTIME_CLOSED"); + } + }; + const release = () => { + cleanup ??= chain(resolvePrivatePromise(), async () => { + // Startup can still reserve producer work. Join both before releasing + // facades, without joining the stream handler that calls this cleanup. + if (startup) await chain(startup, () => {}, () => {}); + if (producerCompletion) await observePrivatePromise(producerCompletion); + if (resourcesStarted) await observePrivatePromise(facades.cleanup()); + }); + return cleanup; + }; + function close(): Promise { + if (closing) return closing; + closing = chain(resolvePrivatePromise(), async () => { + if (preparation) await chain(preparation, () => {}, () => {}); + let failed = false; + try { + await release(); + } catch { + failed = true; + } + try { + await observePrivatePromise(input.project.close()); + } catch { + failed = true; + } + preparedOperations = undefined; + if (failed) refuse("EXECUTOR_RUNTIME_CLEANUP_FAILED"); + }); + void chain(closing, settled.resolve, settled.reject); + apply(abortController, lifetime, []); + apply(removeEventListener, input.project.signal, ["abort", onDiscoveryAbort]); + return closing; + } + const onDiscoveryAbort = () => { + void chain(close(), () => {}, () => {}); + }; + apply(addEventListener, input.project.signal, ["abort", onDiscoveryAbort, { once: true }]); + if (input.project.signal.aborted) onDiscoveryAbort(); + + function requireFacades( + definition: RuntimeAgentMarkdownDefinition, + effective: ExecutorRuntimeGrantData, + ) { + if ( + typeof facades.resolveModelRuntime !== "function" || typeof facades.cleanup !== "function" + ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + for (let index = 0; index < effective.hostToolFacadeIds.length; index++) { + const id = effective.hostToolFacadeIds[index]; + if (id === undefined) continue; + if (!privateMapHas(facades.hostTools, id)) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + for (let index = 0; index < effective.remoteToolSourceIds.length; index++) { + const id = effective.remoteToolSourceIds[index]; + if (id === undefined) continue; + if (!privateMapHas(facades.remoteToolSources, id)) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + } + const configuredServers = definition.mcpServers ?? []; + for (let index = 0; index < configuredServers.length; index++) { + const server = configuredServers[index]; + if (server === undefined) continue; + if (!includes(effective.remoteToolSourceIds, server.id ?? server.kind)) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + } + if ( + (effective.execution.projectId !== null || + includes(effective.requiredCapabilities ?? [], "project-steering")) && + (typeof facades.projectSteering?.prepare !== "function" || + typeof facades.projectSteering?.refresh !== "function") + ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + if ( + includes(effective.requiredCapabilities ?? [], "conversation-user-text") && + typeof facades.latestConversationUserText !== "function" + ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + if (effective.execution.kind === "canonical") { + if ( + typeof facades.publishParentRunEvents !== "function" || + typeof facades.toolExposureCheckpoint?.persist !== "function" + ) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + if ( + effective.execution.providerReplay === "required" && + typeof facades.providerReplayCheckpoint?.persist !== "function" + ) refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + } + + async function prepare( + request: ExecutorRuntimePrepareRequest, + context: ExecutorOperationContext, + ): Promise { + try { + assertActive(); + if (!grant || grant.agentId !== request.agentId || !sameBinding(binding, context.binding)) { + refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + } + const preparedSource = await observePrivatePromise( + input.project.prepare(request.agentId, context), + ); + const described = parseDiscoveryData( + getExecutorAgentDescribeResultSchema(), + preparedSource.description, + true, + ); + if ( + !described.ok || described.value.definition.id !== grant.agentId || + verifyHostedRuntimeSourceBinding(source, described.value.source) + ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + const definition = described.value.definition; + const modelId = request.modelId ?? grant.defaultModelId; + const modelGrant = privateMapGet(modelGrants, modelId); + if ( + !modelGrant || (request.maxSteps !== undefined && request.maxSteps > grant.maxSteps) || + (request.maxOutputTokens !== undefined && + request.maxOutputTokens > modelGrant.maxOutputTokens) + ) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + const thinking = request.thinking ?? definition.thinking ?? + resolveVeryfrontCloudModelThinking(modelId); + let availableOutputTokens = modelGrant.maxOutputTokens; + const modelProvider = tryGetVeryfrontCloudProviderFromModelId(modelId); + if (modelProvider === "anthropic") { + try { + const effectiveThinking = thinking ?? resolveVeryfrontCloudModelThinking(modelId); + const model = { id: modelId, modelId, provider: modelProvider }; + const options = { + reasoning: resolveVeryfrontCloudReasoningOption(modelId, effectiveThinking), + providerOptions: resolveVeryfrontCloudThinkingProviderOptions( + modelId, + effectiveThinking, + ), + }; + objectSetPrototypeOf(model, null); + objectSetPrototypeOf(options, null); + availableOutputTokens -= getExecutorModelAdditiveReasoningTokens({ model, options }); + } catch { + refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + } + } + const maxOutputTokens = request.maxOutputTokens ?? availableOutputTokens; + if ( + !numberIsSafeInteger(maxOutputTokens) || maxOutputTokens <= 0 || + maxOutputTokens > availableOutputTokens + ) { + refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + } + requireFacades(definition, grant); + // Enroll only after agent.describe returns: a failed discovery operation + // can await discovery.close(). No remaining preparation work awaits it. + input.project.retainTask(preparation!); + let localTools: HostToolSet = preparedSource.localTools; + for (let index = 0; index < grant.hostToolFacadeIds.length; index++) { + const id = grant.hostToolFacadeIds[index]; + if (id === undefined) continue; + // Object spread creates own data properties without invoking mutable + // Object.assign or inherited setters with private facade values. + localTools = { ...localTools, ...privateMapGet(facades.hostTools, id) }; + } + const normalizeGrantedToolNames = (names: readonly string[]) => [ + ...resolveOwnerScopedToolNames({ + toolNames: names, + agentId: definition.id, + localTools, + })!, + ]; + // Project metadata may resolve selectors, never rewrite trusted authority. + const normalizeToolNames = (names: readonly string[]) => + normalizeGrantedToolNames( + preparedSource.toolAliases + ? mapPrivateArray(names, (name) => preparedSource.toolAliases!.get(name) ?? name) + : names, + ); + const deniedToolSet = createPrivateSet(definition.deniedTools ?? []); + const normalizedDenials = normalizeToolNames(definition.deniedTools ?? []); + for (let index = 0; index < normalizedDenials.length; index++) { + const name = normalizedDenials[index]; + if (name !== undefined) deniedToolSet.add(name); + } + const deniedToolNames = [...deniedToolSet]; + const sourceToolNames = resolveHostedRuntimeAllowedTools({ + configuredTools: definition.tools, + configuredDeniedTools: definition.deniedTools, + configuredDelegates: definition.delegates, + configuredSkills: definition.skills, + requestedTools: undefined, + }); + let allowedToolNames = intersectNames( + normalizeGrantedToolNames(grant.allowedToolNames), + arrayIsArray(sourceToolNames) ? normalizeToolNames(sourceToolNames) : sourceToolNames, + request.allowedToolNames === undefined + ? undefined + : normalizeToolNames(request.allowedToolNames), + deniedToolNames, + ); + if (includes(allowedToolNames, "studio_open_project")) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + const providerToolNames = intersectNames( + modelGrant.providerToolNames, + resolveHostedRuntimeAllowedProviderTools({ + configuredProviderTools: definition.providerTools, + requestedTools: undefined, + }), + request.providerToolNames, + definition.deniedTools, + ); + const resolveModelRuntime: AgentModelRuntimeResolver = (id) => { + assertActive(); + if (!privateMapHas(modelGrants, id)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + return facades.resolveModelRuntime(id) ?? refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + }; + registerModelRuntimeResolverRevoker( + resolveModelRuntime, + () => revokeModelRuntimeResolver(installedModelResolver), + ); + // The first facade call can reserve resources before throwing. + resourcesStarted = true; + resolveModelRuntime(modelId); + const execution = grant.execution; + const steeringResult = facades.projectSteering + ? await observePrivatePromise(facades.projectSteering.prepare({ + definition, + projectId: execution.projectId, + branchId: execution.branchId, + signal: context.signal, + })) + : undefined; + const steering = steeringResult === undefined ? undefined : parseRuntimePreparationData( + getExecutorRuntimeSteeringSchema(), + steeringResult, + ); + assertActive(); + if (steering && steering.agent.id !== definition.id) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + const skills = resolveRuntimeSkillSelectorForAgent({ + skills: steering?.initialSkills ?? [], + agentId: definition.id, + selector: definition.skills === false ? [] : definition.skills, + }); + if (sourceToolNames !== undefined || request.allowedToolNames === undefined) { + const effectiveSourceTools = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: normalizeToolNames(sourceToolNames ?? allowedToolNames), + localToolNames: filter( + normalizeGrantedToolNames(grant.allowedToolNames), + (name) => hasOwn(localTools, name), + ), + availableSkillIds: skills.allowedSkillIds, + configDerivedSelector: request.allowedToolNames === undefined && + !(definition.tools === true && (definition.deniedTools?.length ?? 0) > 0), + }); + allowedToolNames = intersectNames( + normalizeGrantedToolNames(grant.allowedToolNames), + effectiveSourceTools === null ? undefined : [...effectiveSourceTools], + request.allowedToolNames === undefined + ? undefined + : normalizeToolNames(request.allowedToolNames), + deniedToolNames, + ); + } + const taskContext = { + ...execution, + steeringRevision: 0, + agentId: definition.id, + model: modelId, + availableSkillIds: skills.allowedSkillIds, + ...(execution.kind === "canonical" + ? { parentRunId: execution.runId, parentMessageId: execution.messageId } + : {}), + }; + objectSetPrototypeOf(taskContext, null); + const options: PreparedHostedRuntimeAgentOptions["options"] = { + ...execution, + agentId: definition.id, + model: modelId, + instructions: request.instructions ?? + (steering + ? buildInteractiveVeryfrontCloudRuntimeInstructions({ + agentConfig: definition, + projectId: execution.projectId, + branchId: execution.branchId, + instructions: steering.initialProjectInstructions ?? "", + skills: includes(allowedToolNames, "load_skill") ? skills.definitions : [], + environmentContext: steering.environmentContext, + availableToolNames: allowedToolNames, + }) + : definition.system ?? definition.instructions), + temperature: request.temperature ?? definition.temperature, + thinking, + maxSteps: mathMin( + request.maxSteps ?? grant.maxSteps, + definition.maxSteps ?? grant.maxSteps, + grant.maxSteps, + ), + maxOutputTokens, + allowedTools: allowedToolNames, + allowedProviderTools: providerToolNames, + availableSkillIds: skills.allowedSkillIds, + skillSelectorPolicy: skills.policy, + skillSourcePaths: skills.skillSourcePaths, + ...(steering + ? { + liveProjectSteering: { + ...steering, + agent: definition, + initialSkills: skills.definitions, + }, + } + : {}), + ...(execution.kind === "canonical" + ? { + parentRunId: execution.runId, + parentMessageId: execution.messageId, + publishParentRunEvents: facades.publishParentRunEvents, + persistToolExposureCheckpoint: facades.toolExposureCheckpoint!.persist, + serverResolvedToolExposureCheckpoint: facades.toolExposureCheckpoint!.initial, + requireToolExposureCheckpointPersistence: true, + ...(execution.providerReplay === "required" + ? { + persistProviderReplayCheckpoint: facades.providerReplayCheckpoint!.persist, + serverResolvedProviderReplayCheckpoints: facades.providerReplayCheckpoint!.initial, + providerReplayCheckpointMessageId: execution.messageId, + requireProviderReplayCheckpointPersistence: true, + } + : {}), + } + : {}), + }; + objectSetPrototypeOf(options, null); + const remoteToolSources: RemoteToolSource[] = []; + for (let index = 0; index < grant.remoteToolSourceIds.length; index++) { + const id = grant.remoteToolSourceIds[index]; + if (id === undefined) continue; + let remoteToolSource = privateMapGet(facades.remoteToolSources, id)!; + const servers = filter( + definition.mcpServers ?? [], + (server) => (server.id ?? server.kind) === id, + ); + for (let serverIndex = 0; serverIndex < servers.length; serverIndex++) { + const server = servers[serverIndex]; + if (server !== undefined) { + remoteToolSource = wrapRemoteToolSourceWithMcpPolicy( + remoteToolSource, + server.toolPolicy, + ); + } + } + defineOwnDataProperty( + remoteToolSources, + remoteToolSources.length, + remoteToolSource, + { enumerable: true, configurable: true, writable: true }, + ); + } + const facadeAllowedToolSet = createPrivateSet(); + for (let index = 0; index < allowedToolNames.length; index++) { + const name = allowedToolNames[index]; + if (name !== undefined) facadeAllowedToolSet.add(name); + } + for (let index = 0; index < providerToolNames.length; index++) { + const name = providerToolNames[index]; + if (name !== undefined) facadeAllowedToolSet.add(name); + } + const facadeAllowedToolNames = [...facadeAllowedToolSet]; + const assemblyInput: Parameters[0] = { + signal: context.signal, + taskContext, + instructions: options.instructions, + localTools: selectAllowedHostTools(localTools, facadeAllowedToolNames), + sourceIntegrationPolicy: preparedSource.sourceIntegrationPolicy, + hostToolPolicy: { allow: facadeAllowedToolNames }, + allowedToolNames, + deniedToolNames, + allowedProviderToolNames: providerToolNames, + sourceProviderToolNames: definition.providerTools, + prepareRemoteToolInput: ({ toolName, toolInput }) => + applyDefaultResearchArtifactPath(toolName, toolInput, taskContext), + shouldRetryWithRemoteTool: ({ toolName, toolInput, error }) => + shouldRetryCreateResearchArtifactAsUpdate({ + toolName, + toolInput, + taskContext, + error, + }), + remoteToolSources, + onSteeringMutation: (mutation) => { + if (mutation.instructionsChanged || mutation.skillsChanged) { + incrementSteeringRevision(taskContext); + } + }, + loadLatestConversationUserText: facades.latestConversationUserText, + }; + objectSetPrototypeOf(assemblyInput, null); + const toolAssembly = await observePrivatePromise( + prepareFacadedHostedChatRuntimeToolAssembly(assemblyInput), + ); + assertActive(); + for (const name of toolAssembly.normalizedAllowedToolNames ?? []) { + if (!includes(toolAssembly.authorizedToolNames, name)) { + refuse("EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE"); + } + } + const scopedAssembly = { + ...toolAssembly, + runtimeTools: scopeHostedRuntimeToolResults(toolAssembly.runtimeTools), + }; + objectSetPrototypeOf(scopedAssembly, null); + const runtimeInput: PreparedHostedRuntimeAgentOptions = { + options, + taskContext, + toolAssembly: scopedAssembly, + modelId, + sourceIntegrationPolicy: preparedSource.sourceIntegrationPolicy, + refreshSystem: facades.projectSteering + ? () => facades.projectSteering!.refresh(streamSignal, toolAssembly.modelVisibleToolNames) + : undefined, + }; + const runtimeOptions: NonNullable[1]> = { + resolveModelRuntime, + preserveToolCatalog: true, + onStreamCompletion: (completion) => { + producerCompletion = completion; + input.project.retainTask(completion); + }, + }; + objectSetPrototypeOf(runtimeInput, null); + objectSetPrototypeOf(runtimeOptions, null); + const runtimeAgent = input.project.instantiate(runtimeInput, runtimeOptions); + assertActive(); + const preparedRuntimeHandle = crypto.randomUUID(); + preparedOperations = createExecutorAgentOperations({ + preparedRuntimeHandle, + startStream: (streamInput) => { + streamSignal = streamInput.abortSignal; + startup = chain(resolvePrivatePromise(), () => { + assertActive(); + const streamOptions: Parameters[0] = { + runtimeAgent, + sourceIntegrationPolicy: preparedSource.sourceIntegrationPolicy, + agentId: definition.id, + projectId: execution.projectId ?? undefined, + projectSlug: execution.projectSlug, + ...(execution.kind === "canonical" + ? { runId: execution.runId, conversationId: execution.conversationId } + : {}), + maxOutputTokens: options.maxOutputTokens, + }; + objectSetPrototypeOf(streamOptions, null); + return createHostedChatRuntimeDataStream(streamOptions, streamInput); + }); + input.project.retainTask(startup); + return startup; + }, + cleanup: release, + }); + return executorAgentJson({ + ok: true, + value: { preparedRuntimeHandle, runtimeKind: "framework", modelId }, + }, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + } catch (error) { + try { + await release(); + } catch { + return { ok: false, code: "EXECUTOR_RUNTIME_CLEANUP_FAILED" }; + } + const knownFailure = executorAgentFailureCode(error, "EXECUTOR_AGENT_SETUP_FAILED"); + const code = lifetime.signal.aborted + ? "ABORTED" + : error instanceof ExecutorRuntimePreparationError + ? error.code + : knownFailure === "EXECUTOR_AGENT_SETUP_FAILED" + ? "EXECUTOR_RUNTIME_PREPARATION_FAILED" + : knownFailure; + return { ok: false, code }; + } + } + + const operations = new Map(); + operations.set("runtime.prepare", { + mode: "unary", + async handle(value, context) { + try { + assertActive(); + if (preparation) refuse("EXECUTOR_RUNTIME_ALREADY_PREPARED"); + const request = parseRuntimePreparationData( + getExecutorRuntimePrepareRequestSchema(), + value, + ); + executorAgentJson(request, "EXECUTOR_AGENT_INPUT_TOO_LARGE"); + const cancel = () => { + void chain(close(), () => {}, () => {}); + }; + apply(addEventListener, context.signal, ["abort", cancel, { once: true }]); + preparation = prepare(request, { + ...context, + signal: combineSignals(context.signal, lifetime.signal), + }); + if (context.signal.aborted) cancel(); + try { + return await observePrivatePromise(preparation); + } finally { + apply(removeEventListener, context.signal, ["abort", cancel]); + } + } catch (error) { + return { + ok: false, + code: error instanceof ExecutorRuntimePreparationError + ? error.code + : "EXECUTOR_RUNTIME_INVALID_INPUT", + }; + } + }, + }); + operations.set("agent.stream", { + mode: "stream", + async *handle(value, context) { + assertActive(); + if (!sameBinding(binding, context.binding)) refuse("EXECUTOR_RUNTIME_NOT_GRANTED"); + const operation = preparedOperations === undefined + ? undefined + : privateMapGet(preparedOperations, "agent.stream"); + if (operation?.mode !== "stream") refuse("EXECUTOR_RUNTIME_NOT_PREPARED"); + yield* getPrivateAsyncIterator(operation.handle(value, { + ...context, + signal: combineSignals(context.signal, lifetime.signal), + })); + }, + }); + return { operations, close, settled: settled.promise, signal: lifetime.signal }; +} diff --git a/src/agent/hosted/trusted-runtime-prepare.test.ts b/src/agent/hosted/trusted-runtime-prepare.test.ts new file mode 100644 index 0000000000..42c6e426a0 --- /dev/null +++ b/src/agent/hosted/trusted-runtime-prepare.test.ts @@ -0,0 +1,443 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; +import { tool } from "#veryfront/tool/factory.ts"; +import { + createExecutorChannel, + type ExecutorOperation, +} from "#veryfront/agent/executor/channel.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import { + createExecutorProjectToolOperations, + createExecutorProjectToolSource, +} from "#veryfront/agent/hosted/executor-project-tools.ts"; +import { createTrustedRuntimePreparation } from "#veryfront/agent/hosted/trusted-runtime-prepare.ts"; +import type { + ExecutorRuntimeFacades, + ExecutorRuntimePreparationGrant, +} from "#veryfront/agent/hosted/executor-runtime-prepare.ts"; + +const binding = { + allocationId: "synthetic-allocation", + invocationId: "synthetic-invocation", + generation: 1, +}; +const source = { type: "release", releaseId: "synthetic-release" } as const; +const modelId = "veryfront-cloud/openai/gpt-5.4"; +const context = { agentId: "coder", runId: "synthetic-run", projectId: "synthetic-project" }; +const definition = { + id: "coder", + name: "Coder", + description: "Synthetic agent", + instructions: "Synthetic source instructions", + model: modelId, + tools: ["inspect"], + skills: false, + maxSteps: 3, +}; +const grant: ExecutorRuntimePreparationGrant = { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 3, + models: new Map([[modelId, { maxOutputTokens: 200, providerToolNames: [] }]]), + allowedToolNames: ["inspect"], + hostToolFacadeIds: [], + remoteToolSourceIds: ["project"], + execution: { + kind: "canonical", + projectId: context.projectId, + runId: context.runId, + conversationId: "synthetic-conversation", + messageId: "synthetic-message", + providerReplay: "disabled", + }, +}; + +async function fixture(options: { + ownedTool?: boolean; + forgedAlias?: boolean; + sourceTools?: string[]; + deniedTools?: string[]; + channelTimeoutMs?: number; + describe?: Extract["handle"]; + facades?: Partial; + facadeInstance?: (facades: ExecutorRuntimeFacades) => ExecutorRuntimeFacades; + closeProject?: () => Promise; +} = {}) { + const lifetime = new AbortController(); + const seen: unknown[] = []; + const requested: unknown[] = []; + let closed = 0; + let cleaned = 0; + const checkpoints: unknown[] = []; + const toolName = options.forgedAlias + ? "dangerous" + : options.ownedTool + ? "coder--inspect" + : "inspect"; + const model = scriptedModel([ + { + toolCalls: [{ id: "synthetic-call", name: toolName, input: { query: "authorized input" } }], + }, + { text: "synthetic-private-model-output" }, + ]); + const registered = tool({ + id: toolName, + description: "Inspect a query", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: (args, call) => { + seen.push({ + args, + context: call && + { + agentId: call.agentId, + runId: call.runId, + projectId: call.projectId, + toolCallId: call.toolCallId, + }, + }); + return { ok: true }; + }, + }); + if (options.ownedTool) { + registered.ownerAgentId = "coder"; + registered.shortName = "inspect"; + } + const operations = new Map(createExecutorProjectToolOperations({ + scope: { binding, signal: lifetime.signal, assertActive() {} }, + context, + tools: new Map([[toolName, registered]]), + allowedToolNames: new Set([toolName]), + maxCalls: 32, + maxConcurrent: 2, + })); + if (options.forgedAlias) { + operations.set("project.tool-aliases", { + mode: "unary", + handle: () => ({ agentId: "coder", aliases: [{ name: "dangerous", shortName: "inspect" }] }), + }); + } + operations.set("agent.describe", { + mode: "unary", + handle: options.describe ?? ((value) => { + requested.push(value); + return { + ok: true, + value: { + source, + definition: { + ...definition, + tools: options.sourceTools ?? [toolName], + ...(options.deniedTools ? { deniedTools: options.deniedTools } : {}), + }, + }, + }; + }), + }); + const forward = new TransformStream(); + const backward = new TransformStream(); + const trusted = createExecutorChannel({ + binding, + ...(options.channelTimeoutMs ? { defaultTimeoutMs: options.channelTimeoutMs } : {}), + transport: { readable: backward.readable, writable: forward.writable }, + }); + const project = createExecutorChannel({ + binding, + operations, + transport: { readable: forward.readable, writable: backward.writable }, + }); + const projectTools = await createExecutorProjectToolSource({ + channel: trusted, + signal: lifetime.signal, + context, + allowedToolNames: new Set([toolName]), + assertActive() {}, + }); + const facades: ExecutorRuntimeFacades = { + resolveModelRuntime: () => model, + hostTools: new Map(), + remoteToolSources: new Map([["project", projectTools]]), + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => "synthetic-private-instructions", + }, + publishParentRunEvents: () => Promise.resolve(), + toolExposureCheckpoint: { + persist: (checkpoint) => { + checkpoints.push(checkpoint); + return Promise.resolve(); + }, + }, + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + ...options.facades, + }; + const owner = createTrustedRuntimePreparation({ + binding, + source, + channel: trusted, + signal: lifetime.signal, + sourceIntegrationPolicy: { schemaVersion: 1, mode: "unrestricted" }, + grant: { + ...grant, + allowedToolNames: options.forgedAlias ? ["inspect"] : [toolName], + hostToolFacadeIds: options.forgedAlias ? ["host"] : [], + }, + projectTools, + facades: options.facadeInstance?.(facades) ?? facades, + async closeProject() { + assertEquals(this.channel, trusted, "Project cleanup retains its constructor receiver"); + closed++; + trusted.close(); + await Promise.all([trusted.settled, project.settled]); + await options.closeProject?.(); + }, + }); + const operationContext = { + binding, + signal: new AbortController().signal, + deadline: Date.now() + 30_000, + }; + return { + owner, + model, + seen, + requested, + checkpoints, + lifetime, + trusted, + project, + get closed() { + return closed; + }, + get cleaned() { + return cleaned; + }, + async prepare( + value: JsonValue = { agentId: "coder", instructions: "synthetic-private-instructions" }, + ) { + const operation = owner.operations.get("runtime.prepare"); + assert(operation?.mode === "unary"); + return await operation.handle(value, operationContext); + }, + async stream(handle: string) { + const operation = owner.operations.get("agent.stream"); + assert(operation?.mode === "stream"); + return await Array.fromAsync( + operation.handle({ + preparedRuntimeHandle: handle, + messages: [{ + id: "input", + role: "user", + parts: [{ type: "text", text: "synthetic-private-conversation" }], + timestamp: 1, + }], + }, operationContext), + ); + }, + }; +} + +describe("trusted runtime preparation", () => { + it("does not let peer aliases widen a trusted host-tool grant", async () => { + let executed = 0; + const dangerous = tool({ + id: "dangerous", + description: "Synthetic host operation", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: () => { + executed++; + return { ok: true }; + }, + }); + const f = await fixture({ + forgedAlias: true, + sourceTools: ["inspect"], + facades: { hostTools: new Map([["host", { dangerous }]]) }, + }); + try { + const prepared = await f.prepare() as { + ok: boolean; + value: { preparedRuntimeHandle: string }; + }; + if (prepared.ok) await f.stream(prepared.value.preparedRuntimeHandle); + assertEquals(executed, 0, "Peer-owned aliases cannot authorize host capabilities"); + } finally { + await f.owner.close(); + } + }); + it("retains private facade class methods and their original receivers", async () => { + let resolutions = 0; + const f = await fixture({ + facadeInstance: (defaults) => + new class implements ExecutorRuntimeFacades { + #defaults = defaults; + hostTools = defaults.hostTools; + remoteToolSources = defaults.remoteToolSources; + projectSteering = defaults.projectSteering; + toolExposureCheckpoint = defaults.toolExposureCheckpoint; + publishParentRunEvents = defaults.publishParentRunEvents; + resolveModelRuntime(id: string) { + resolutions++; + return this.#defaults.resolveModelRuntime(id); + } + cleanup() { + return this.#defaults.cleanup(); + } + }(), + }); + try { + assertEquals((await f.prepare() as { ok: boolean }).ok, true); + assert(resolutions > 0); + } finally { + await f.owner.close(); + } + assertEquals(f.cleaned, 1); + }); + for (const denied of [false, true]) { + it(`preserves owned tool short-name selectors and denials (${denied})`, async () => { + const f = await fixture({ + ownedTool: true, + sourceTools: denied ? ["coder--inspect"] : ["inspect"], + deniedTools: denied ? ["inspect"] : undefined, + }); + try { + const prepared = await f.prepare() as { + ok: boolean; + value: { preparedRuntimeHandle: string }; + }; + assertEquals(prepared.ok, true); + await f.stream(prepared.value.preparedRuntimeHandle); + assertEquals(f.seen.length, denied ? 0 : 1); + } finally { + await f.owner.close(); + } + }); + } + it("respects a metadata channel timeout shorter than the preparation deadline", async () => { + const f = await fixture({ channelTimeoutMs: 1000 }); + try { + assertEquals((await f.prepare() as { ok: boolean }).ok, true); + } finally { + await f.owner.close(); + } + }); + it("executes the existing canonical runtime using only metadata and remote project tools", async () => { + const f = await fixture(); + try { + assertEquals([...f.owner.operations.keys()], ["runtime.prepare", "agent.stream"]); + const prepared = await f.prepare() as { + ok: boolean; + value: { preparedRuntimeHandle: string }; + }; + assertEquals(prepared.ok, true); + const frames = await f.stream(prepared.value.preparedRuntimeHandle); + assertEquals(frames[0], { type: "ready" }); + assertEquals(frames.at(-1), { type: "complete" }); + assertEquals(f.model.callCount, 2); + assertEquals(f.seen, [{ + args: { query: "authorized input" }, + context: { ...context, toolCallId: "synthetic-call" }, + }]); + assertEquals(f.requested, [{ agentId: "coder" }]); + assert(f.model.systemPrompts()[0]?.includes("synthetic-private-instructions")); + assert(JSON.stringify(frames).includes("synthetic-private-model-output")); + assertEquals( + f.checkpoints, + [], + "A fixed eager catalog does not emit deferred-loading checkpoints", + ); + } finally { + await f.owner.close(); + await f.owner.settled; + } + assertEquals(f.closed, 1); + assertEquals(f.cleaned, 1); + }); + for (const mismatch of ["agent", "source"]) { + it(`rejects ${mismatch} metadata before private facade calls`, async () => { + let privateCalls = 0; + const f = await fixture({ + describe: () => ({ + ok: true, + value: { + source: mismatch === "source" ? { ...source, releaseId: "other" } : source, + definition: { ...definition, id: mismatch === "agent" ? "other" : "coder" }, + }, + }), + facades: { + resolveModelRuntime: () => { + privateCalls++; + return undefined; + }, + }, + }); + try { + assertEquals(await f.prepare(), { ok: false, code: "EXECUTOR_RUNTIME_NOT_GRANTED" }); + assertEquals(privateCalls, 0); + } finally { + await f.owner.close(); + } + assertEquals(f.closed, 1); + }); + } + it("requires canonical persistence capabilities without downgrading the invocation", async () => { + const f = await fixture({ facades: { publishParentRunEvents: undefined } }); + try { + assertEquals(await f.prepare(), { + ok: false, + code: "EXECUTOR_RUNTIME_CAPABILITY_UNAVAILABLE", + }); + assertEquals(f.model.callCount, 0); + } finally { + await f.owner.close(); + } + }); + it("retains original peer work when cancellation interrupts metadata discovery", async () => { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const f = await fixture({ + describe: async (_value, context) => { + context.signal.addEventListener("abort", () => aborted.resolve(), { once: true }); + started.resolve(); + await finish.promise; + return { ok: true, value: { source, definition } }; + }, + }); + try { + const preparing = f.prepare(); + await started.promise; + f.lifetime.abort(); + const closing = f.owner.close(); + let retired = false; + void closing.then(() => { + retired = true; + }); + await aborted.promise; + assertEquals(retired, false); + finish.resolve(); + await preparing; + await closing; + await f.owner.settled; + assertEquals(retired, true); + assertEquals(f.model.callCount, 0); + } finally { + finish.resolve(); + await f.owner.close(); + } + assertEquals(f.closed, 1); + }); + it("surfaces project cleanup failure and invokes cleanup only once", async () => { + const f = await fixture({ + closeProject: () => Promise.reject(new Error("Synthetic cleanup failure")), + }); + await assertRejects(() => f.owner.close()); + await assertRejects(() => f.owner.settled); + await assertRejects(() => f.owner.close()); + assertEquals(f.closed, 1); + }); +}); diff --git a/src/agent/hosted/trusted-runtime-prepare.ts b/src/agent/hosted/trusted-runtime-prepare.ts new file mode 100644 index 0000000000..ba1b48f640 --- /dev/null +++ b/src/agent/hosted/trusted-runtime-prepare.ts @@ -0,0 +1,89 @@ +import type { ExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import type { ExecutorBinding } from "#veryfront/agent/executor/protocol.ts"; +import type { ExecutorDiscoverySource } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { + createPreparedHostedRuntimeAgent, + type PreparedHostedRuntimeAgentOptions, +} from "#veryfront/agent/hosted/default-chat-runtime.ts"; +import { parseSourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import { snapshotOwnDataRecords } from "#veryfront/security/own-data-record.ts"; +import type { ExecutorProjectToolSource } from "#veryfront/agent/hosted/executor-project-tools.ts"; +import { + createRuntimePreparationCore, + type ExecutorRuntimeFacades, + type ExecutorRuntimePreparationGrant, +} from "#veryfront/agent/hosted/runtime-preparation-core.ts"; + +export interface TrustedRuntimePreparationOptions { + binding: ExecutorBinding; + source: ExecutorDiscoverySource; + channel: ExecutorChannel; + sourceIntegrationPolicy: PreparedHostedRuntimeAgentOptions["sourceIntegrationPolicy"]; + grant: ExecutorRuntimePreparationGrant; + projectTools: ExecutorProjectToolSource; + facades: ExecutorRuntimeFacades; + signal: AbortSignal; + /** Settles only after the original project work and transport/allocation resources retire. */ + closeProject(): Promise; +} + +/** Trusted-only composition. Project metadata crosses the channel; project modules never load here. */ +export function createTrustedRuntimePreparation(input: TrustedRuntimePreparationOptions) { + const policy = parseSourceIntegrationPolicyManifest( + snapshotOwnDataRecords(input.sourceIntegrationPolicy), + ); + const { channel } = input; + const closeProject = input.closeProject.bind(input); + const aliases = new Map( + input.projectTools.aliases.map(({ name, shortName }) => [shortName, name]), + ); + const signal = AbortSignal.any([input.signal, channel.signal]); + const tasks = new Set>(); + let closing: Promise | undefined; + return createRuntimePreparationCore({ + binding: input.binding, + source: input.source, + grant: input.grant, + facades: input.facades, + projectTools: input.projectTools, + project: { + signal, + async prepare(agentId, context) { + const deadline = new AbortController(); + const remaining = context.deadline - Date.now(); + if (remaining <= 0) throw new Error("Preparation deadline expired"); + const timer = setTimeout(() => deadline.abort(), remaining); + try { + const description = await channel.request("agent.describe", { agentId }, { + signal: AbortSignal.any([context.signal, deadline.signal]), + }); + return { + description, + localTools: {}, + toolAliases: aliases, + sourceIntegrationPolicy: policy, + }; + } finally { + clearTimeout(timer); + } + }, + instantiate: (options, runtimeOptions) => + createPreparedHostedRuntimeAgent({ + ...options, + runtimeAgentId: options.options.agentId, + }, runtimeOptions), + retainTask(task) { + const retained = task.then(() => {}, () => {}); + tasks.add(retained); + void retained.then(() => tasks.delete(retained)); + }, + close() { + closing ??= (async () => { + while (tasks.size > 0) await Promise.all(tasks); + await closeProject(); + })(); + return closing; + }, + }, + }); +} diff --git a/tests/integration/agent/fixtures/trusted-project-executor.ts b/tests/integration/agent/fixtures/trusted-project-executor.ts new file mode 100644 index 0000000000..4bc7fd99d5 --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-project-executor.ts @@ -0,0 +1,82 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { register } from "#veryfront/extensions/contracts.ts"; +import { EsbuildBundler, EsModuleLexer } from "@veryfront/ext-bundler-esbuild"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { createExecutorDiscovery } from "#veryfront/agent/hosted/executor-discovery.ts"; +import { createNodeExecutorDiscoveryBackend } from "#veryfront/agent/hosted/executor-discovery-node.ts"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { createExecutorProjectToolOperations } from "#veryfront/agent/hosted/executor-project-tools.ts"; +import { listenExecutorTransport } from "#veryfront/agent/hosted/executor-node-transport.ts"; + +const lines = createInterface({ input: process.stdin }); +register("Bundler", new EsbuildBundler()); +register("ModuleLexer", new EsModuleLexer()); +const first = await lines[Symbol.asyncIterator]().next(); +lines.close(); +if (first.done) throw new Error("Missing synthetic bootstrap"); +const { binding, key, context, mode } = JSON.parse(first.value); +const signal = new AbortController().signal; +const source = { type: "release", releaseId: "synthetic-release" } as const; +const projectDir = fileURLToPath(new URL("./trusted-project", import.meta.url)); +const backend = createNodeExecutorDiscoveryBackend({ projectDir, cacheKey: "synthetic-native" }); +const discovery = createExecutorDiscovery({ + binding, + source, + signal, + projectDir, + backend, +}); +const describe = discovery.operations.get("agent.describe"); +if (describe?.mode !== "unary") throw new Error("Missing discovery operation"); +const description = await describe.handle({ + agentId: mode === "startup-failure" ? "missing" : "coder", +}, { binding, signal, deadline: Date.now() + 30_000 }); +if ( + typeof description !== "object" || description === null || Array.isArray(description) || + !description.ok +) { + await discovery.close(); + throw new Error("Synthetic project discovery failed"); +} +const runtime = discovery.getRuntime(); +const operations = new Map(discovery.operations); +for ( + const [name, operation] of createExecutorProjectToolOperations({ + scope: { binding, signal, assertActive() {} }, + context, + tools: runtime.tools, + allowedToolNames: new Set(["inspect"]), + maxCalls: 32, + maxConcurrent: 1, + }) +) operations.set(name, operation); +const listener = await listenExecutorTransport({ + host: "127.0.0.1", + port: 0, + binding, + key: new Uint8Array(key), + timeoutMs: 30_000, +}); +process.stdout.write(`VF_READY ${JSON.stringify({ port: listener.address.port })}\n`); +try { + const transport = await listener.connection; + const channel = createExecutorChannel({ binding, transport, operations }); + await channel.settled; +} finally { + listener.close(); + await discovery.close(); + const globals = globalThis as typeof globalThis & { + __vfNativeObservations?: string[]; + __vfNativeCalls?: number; + }; + process.stdout.write( + `VF_REPORT ${ + JSON.stringify({ + observations: globals.__vfNativeObservations ?? [], + calls: globals.__vfNativeCalls ?? 0, + hasParentSecret: Object.hasOwn(process.env, "VF_NATIVE_PARENT_SECRET"), + }) + }\n`, + ); +} diff --git a/tests/integration/agent/fixtures/trusted-project/agents/coder.ts b/tests/integration/agent/fixtures/trusted-project/agents/coder.ts new file mode 100644 index 0000000000..cac19f9725 --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-project/agents/coder.ts @@ -0,0 +1,15 @@ +import { agent } from "veryfront/agent"; +import { installHooks } from "../probe.ts"; + +export default agent({ + id: "coder", + name: "Synthetic native coder", + model: "veryfront-cloud/anthropic/claude-sonnet-4-6", + system: () => { + installHooks(); + return "Inspect the authorized query."; + }, + tools: { inspect: true }, + skills: false, + maxSteps: 2, +}); diff --git a/tests/integration/agent/fixtures/trusted-project/probe.ts b/tests/integration/agent/fixtures/trusted-project/probe.ts new file mode 100644 index 0000000000..273bfe97f1 --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-project/probe.ts @@ -0,0 +1,54 @@ +type FixtureGlobals = typeof globalThis & { + __vfNativeObservations?: string[]; + __vfNativeCalls?: number; +}; +const globals = globalThis as FixtureGlobals; +const seen: string[] = globals.__vfNativeObservations ??= []; +const stringify = JSON.stringify; +const apply = Reflect.apply; +const test = RegExp.prototype.test; +const marker = /synthetic-trusted-private-[a-f0-9-]{36}/; + +function observe(value: unknown) { + let text: string | undefined; + try { + text = typeof value === "string" ? value : stringify(value); + } catch { + return; + } + if (text && apply(test, marker, [text])) seen.push("private marker observed"); +} + +export function installHooks() { + const trim = String.prototype.trim; + String.prototype.trim = function () { + observe(this); + return apply(trim, this, []); + }; + const map = Array.prototype.map; + Array.prototype.map = function (callback, thisArg) { + observe(this); + return apply(map, this, [callback, thisArg]); + }; + const iterator = Array.prototype[Symbol.iterator]; + Array.prototype[Symbol.iterator] = function () { + observe(this); + return apply(iterator, this, []); + }; + const set = Map.prototype.set; + Map.prototype.set = function (key, value) { + observe(value); + return apply(set, this, [key, value]); + }; + const then = Promise.prototype.then; + Promise.prototype.then = function (this: Promise, fulfilled, rejected) { + return apply(then, this, [(value: unknown) => { + observe(value); + return typeof fulfilled === "function" ? fulfilled(value) : value; + }, rejected]); + } as typeof then; +} + +export function observations(): string[] { + return [...seen]; +} diff --git a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts new file mode 100644 index 0000000000..45f1f7792c --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts @@ -0,0 +1,34 @@ +import { tool } from "veryfront/tool"; +import { defineSchema } from "veryfront/schemas"; +import { observations } from "../probe.ts"; +import process from "node:process"; + +export default tool({ + id: "inspect", + description: "Inspect a synthetic query", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: async (input, context) => { + const globals = globalThis as typeof globalThis & { __vfNativeCalls?: number }; + globals.__vfNativeCalls = (globals.__vfNativeCalls ?? 0) + 1; + if (input.query === "crash") process.exit(23); + if (input.query === "wait") { + await context?.publishDataEvent?.({ type: "fixture.waiting" }); + await new Promise((resolve) => { + if (context?.abortSignal?.aborted) resolve(); + else context?.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + } + return ({ + query: input.query, + context: { + agentId: context?.agentId, + runId: context?.runId, + projectId: context?.projectId, + toolCallId: context?.toolCallId, + }, + fields: Object.keys(context ?? {}).sort(), + observations: observations(), + hasParentSecret: Object.hasOwn(process.env, "VF_NATIVE_PARENT_SECRET"), + }); + }, +}); diff --git a/tests/integration/agent/fixtures/trusted-project/veryfront.config.ts b/tests/integration/agent/fixtures/trusted-project/veryfront.config.ts new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-project/veryfront.config.ts @@ -0,0 +1 @@ +export default {}; diff --git a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts new file mode 100644 index 0000000000..4ed81c57fc --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts @@ -0,0 +1,286 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import process from "node:process"; +import { spawn } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { connectExecutorTransport } from "#veryfront/agent/hosted/executor-node-transport.ts"; +import { createExecutorProjectToolSource } from "#veryfront/agent/hosted/executor-project-tools.ts"; +import { createTrustedRuntimePreparation } from "#veryfront/agent/hosted/trusted-runtime-prepare.ts"; +import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpers.ts"; +import type { ProviderReplayCheckpoint } from "#veryfront/agent/runtime/provider-replay.ts"; + +export async function runNativeTrustedScenario( + mode: "complete" | "cancel" | "crash" | "startup-failure" | "denied", +) { + const root = new URL("../../../../", import.meta.url); + const startedAt = performance.now(); + const marker = `synthetic-trusted-private-${randomUUID()}`; + const binding = { + allocationId: "synthetic-allocation", + generation: 1, + invocationId: "synthetic-invocation", + }; + const context = { agentId: "coder", projectId: "synthetic-project", runId: "synthetic-run" }; + const key = randomBytes(32); + const previous = process.env.VF_NATIVE_PARENT_SECRET; + process.env.VF_NATIVE_PARENT_SECRET = marker; + const child = spawn(process.execPath, [ + "--import", + fileURLToPath(new URL("tests/node/resolver.mjs", root)), + fileURLToPath(new URL("./trusted-project-executor.ts", import.meta.url)), + ], { + cwd: fileURLToPath(root), + env: { PATH: process.env.PATH, NODE_ENV: "test", DENO_TESTING: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdin.end(JSON.stringify({ binding, key: [...key], context, mode }) + "\n"); + let output = ""; + let errors = ""; + const ready = Promise.withResolvers(); + child.stdout.on("data", (chunk) => { + output += chunk; + const match = output.match(/VF_READY (\{[^\n]+\})/); + if (match) ready.resolve(JSON.parse(match[1]!).port); + }); + child.stderr.on("data", (chunk) => { + errors += chunk; + }); + const exited = new Promise((resolve) => { + child.once("close", (code) => { + ready.reject(new Error(`Child exited ${code}: ${errors}`)); + resolve(code); + }); + }); + child.once("error", ready.reject); + const timer = setTimeout(() => { + ready.reject(new Error(`Native fixture timeout: ${errors}`)); + child.kill(); + }, 25_000); + let owner: ReturnType | undefined; + try { + if (mode === "startup-failure") { + await assertRejects(() => ready.promise); + assertEquals(await exited, 1); + return; + } + const transport = await connectExecutorTransport({ + binding, + podIp: "127.0.0.1", + port: await ready.promise, + key, + timeoutMs: 30_000, + }); + const channel = createExecutorChannel({ binding, transport }); + const lifetime = new AbortController(); + const signal = lifetime.signal; + const source = await createExecutorProjectToolSource({ + channel, + signal, + context, + allowedToolNames: new Set(["inspect"]), + assertActive() {}, + }); + if (mode === "denied") { + await assertRejects(() => source.executeTool("ungranted", {}, { toolCallId: "denied" })); + await assertRejects(() => + source.executeTool("inspect", { query: "authorized query" }, { + ...context, + projectId: "other", + toolCallId: "denied", + }) + ); + channel.close(); + await channel.settled; + transport.close(); + assertEquals(await exited, 0); + const report = output.match(/VF_REPORT (\{[^\n]+\})/); + assert(report); + assertEquals(JSON.parse(report[1]!), { observations: [], calls: 0, hasParentSecret: false }); + return; + } + const order: string[] = []; + const query = mode === "cancel" ? "wait" : mode === "crash" ? "crash" : "authorized query"; + const rawUse = { type: "tool_use", id: "synthetic-call", name: "inspect", input: { query } }; + const model = scriptedModel([ + () => { + order.push("model:1"); + return { + toolCalls: [{ id: "synthetic-call", name: "inspect", input: { query } }], + providerMetadata: { + anthropic: { + rawAssistantMessages: [[ + { type: "thinking", thinking: "", signature: marker }, + rawUse, + ]], + }, + }, + }; + }, + () => { + order.push("model:2"); + return { + text: marker, + providerMetadata: { + anthropic: { rawAssistantMessages: [[{ type: "text", text: marker }]] }, + }, + }; + }, + ], { provider: "anthropic", modelId: "claude-sonnet-4-6", only: "stream" }); + const modelId = "veryfront-cloud/anthropic/claude-sonnet-4-6"; + const results: unknown[] = []; + const checkpoints: ProviderReplayCheckpoint[] = []; + let runtimeCleanups = 0; + let projectCleanups = 0; + owner = createTrustedRuntimePreparation({ + binding, + source: { type: "release", releaseId: "synthetic-release" }, + channel, + signal, + projectTools: { + ...source, + executeTool: async (name, args, call) => { + const result = await source.executeTool(name, args, { + ...call, + publishDataEvent: async (event) => { + await call?.publishDataEvent?.(event); + if (mode === "cancel" && event.type === "fixture.waiting") lifetime.abort(); + }, + }); + results.push(result); + return result; + }, + }, + sourceIntegrationPolicy: { schemaVersion: 1, mode: "unrestricted" }, + grant: { + agentId: "coder", + defaultModelId: modelId, + maxSteps: 2, + models: new Map([[modelId, { maxOutputTokens: 20_000, providerToolNames: [] }]]), + allowedToolNames: ["inspect"], + hostToolFacadeIds: [], + remoteToolSourceIds: ["project"], + execution: { + kind: "canonical", + projectId: context.projectId, + runId: context.runId, + conversationId: "synthetic-conversation", + messageId: "synthetic-message", + providerReplay: "required", + }, + }, + facades: { + resolveModelRuntime: () => model, + hostTools: new Map(), + remoteToolSources: new Map(), + projectSteering: { + prepare: ({ definition }) => Promise.resolve({ agent: definition }), + refresh: () => marker, + }, + publishParentRunEvents: () => Promise.resolve(), + toolExposureCheckpoint: { persist: () => Promise.resolve() }, + providerReplayCheckpoint: { + persist: async (checkpoint) => { + order.push("persist:start"); + await Promise.resolve(); + checkpoints.push(checkpoint); + order.push("persist:done"); + }, + }, + cleanup: () => { + runtimeCleanups++; + return Promise.resolve(); + }, + }, + closeProject: async () => { + projectCleanups++; + channel.close(); + await channel.settled; + transport.close(); + assertEquals(await exited, mode === "crash" ? 23 : 0, errors); + }, + }); + const operation = owner.operations.get("runtime.prepare")!; + assertEquals(operation.mode, "unary"); + if (operation.mode !== "unary") throw new Error("Invalid preparation operation"); + const opContext = { binding, signal, deadline: Date.now() + 30_000 }; + const prepared = await operation.handle({ + agentId: "coder", + instructions: marker, + thinking: { enabled: false }, + }, opContext) as { ok: boolean; value: { preparedRuntimeHandle: string } }; + assertEquals(prepared.ok, true, JSON.stringify(prepared)); + const stream = owner.operations.get("agent.stream")!; + if (stream.mode !== "stream") throw new Error("Invalid stream operation"); + const reading = Array.fromAsync( + stream.handle({ + preparedRuntimeHandle: prepared.value.preparedRuntimeHandle, + messages: [{ + id: "input", + role: "user", + timestamp: 1, + parts: [{ type: "text", text: marker }], + }], + }, opContext), + ); + if (mode !== "complete") { + await assertRejects(() => reading); + await owner.close(); + assertEquals(runtimeCleanups, 1); + assertEquals(projectCleanups, 1); + assertEquals(model.callCount, 1); + assertEquals(results.length, 0); + if (mode === "cancel") { + const report = output.match(/VF_REPORT (\{[^\n]+\})/); + assert(report); + assertEquals(JSON.parse(report[1]!), { + observations: [], + calls: 1, + hasParentSecret: false, + }); + } + return; + } + const frames = await reading; + assertEquals(frames[0], { type: "ready" }); + assertEquals(frames.at(-1), { type: "complete" }); + assertEquals(results, [{ + query: "authorized query", + context: { ...context, toolCallId: "synthetic-call" }, + fields: ["abortSignal", "agentId", "projectId", "publishDataEvent", "runId", "toolCallId"], + observations: [], + hasParentSecret: false, + }]); + assertEquals(model.callCount, 2); + assertEquals(checkpoints.length, 2); + assertEquals(checkpoints[1]?.messageId, "synthetic-message"); + assertEquals(checkpoints[1]?.providerMessageBlockCounts, [2, 1]); + assert(JSON.stringify(checkpoints[0]).includes(marker)); + assert(order.indexOf("persist:done") >= 0); + assert(order.indexOf("persist:done") < order.indexOf("model:2")); + await owner.close(); + assertEquals(runtimeCleanups, 1); + assertEquals(projectCleanups, 1); + const report = output.match(/VF_REPORT (\{[^\n]+\})/); + assert(report); + assertEquals(JSON.parse(report[1]!), { observations: [], calls: 1, hasParentSecret: false }); + console.info( + JSON.stringify({ + mode, + elapsedMs: Math.round(performance.now() - startedAt), + modelCalls: model.callCount, + }), + ); + } finally { + try { + await owner?.close(); + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill(); + await exited; + if (previous === undefined) delete process.env.VF_NATIVE_PARENT_SECRET; + else process.env.VF_NATIVE_PARENT_SECRET = previous; + } + } +} diff --git a/tests/integration/agent/trusted-runtime-preparation.test.ts b/tests/integration/agent/trusted-runtime-preparation.test.ts new file mode 100644 index 0000000000..153fef0235 --- /dev/null +++ b/tests/integration/agent/trusted-runtime-preparation.test.ts @@ -0,0 +1,52 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +// The framework is portable; this Cloud transport intentionally requires Node TLS PSK. +if ("Deno" in globalThis || "Bun" in globalThis) { + it("verifies trusted runtime process separation on the supported Node transport", { + timeout: 60_000, + }, async () => { + const root = new URL("../../../", import.meta.url); + const child = spawn("node", [ + "--import", + fileURLToPath(new URL("tests/node/resolver.mjs", root)), + "--test", + fileURLToPath(import.meta.url), + ], { + cwd: fileURLToPath(root), + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + const result = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + const timer = setTimeout(() => child.kill(), 55_000); + try { + assertEquals(await result, 0, output); + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill(); + await result; + } + }); +} else { + const { runNativeTrustedScenario } = await import("./fixtures/trusted-runtime-scenario.ts"); + describe("trusted hosted native execution", () => { + for (const mode of ["complete", "cancel", "crash", "startup-failure", "denied"] as const) { + it( + `preserves the trust boundary and original-work ownership on ${mode}`, + { timeout: 30_000 }, + () => runNativeTrustedScenario(mode), + ); + } + }); +} From 87c7875e095458fbc9d25326bb8bb0a32eae3f24 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 15:15:25 +0200 Subject: [PATCH 02/19] feat(agent): install executor profiles restricted to project tools --- .../hosted/executor-project-install.test.ts | 158 +++++++++ .../hosted/executor-project-runtime.test.ts | 201 +++++++++++ src/agent/hosted/executor-project-runtime.ts | 81 +++++ .../hosted/executor-runtime-entrypoint.ts | 98 ++++-- .../hosted/executor-runtime-install-schema.ts | 27 +- src/agent/hosted/executor-runtime-install.ts | 67 +++- tests/fixtures/executor-runtime-process.ts | 1 + .../executor-runtime-entrypoint.fixture.ts | 322 ++++++++++-------- 8 files changed, 775 insertions(+), 180 deletions(-) create mode 100644 src/agent/hosted/executor-project-install.test.ts create mode 100644 src/agent/hosted/executor-project-runtime.test.ts create mode 100644 src/agent/hosted/executor-project-runtime.ts diff --git a/src/agent/hosted/executor-project-install.test.ts b/src/agent/hosted/executor-project-install.test.ts new file mode 100644 index 0000000000..07439166a7 --- /dev/null +++ b/src/agent/hosted/executor-project-install.test.ts @@ -0,0 +1,158 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; +import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import { createExecutorRuntimeInstallation } from "./executor-runtime-install.ts"; + +const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; +const artifact = { + version: 1, + owner: { scopeKind: "global", serviceName: "synthetic-service" }, + source: { type: "release", releaseId: "synthetic-release" }, + root: "project", +} as const; +const request = { + ...artifact, + binding, + mode: "project-tools", + context: { agentId: "coder", projectId: "synthetic-project", runId: "synthetic-run" }, + allowedToolNames: ["inspect"], + maxCalls: 32, + maxConcurrent: 2, +} as const; +const context = (): ExecutorOperationContext => ({ + binding, + signal: new AbortController().signal, + deadline: Date.now() + 10_000, +}); + +async function call( + operations: ReadonlyMap, + name: string, + value: unknown, +) { + const operation = operations.get(name); + if (operation?.mode !== "unary") throw new Error("Missing unary operation"); + return await operation.handle(value as JsonValue, context()); +} +function fixture(pending?: Promise) { + let starts = 0; + let closes = 0; + const retired = Promise.withResolvers(); + const operations = new Map([ + ["discovery.describe", { mode: "unary", handle: () => ({ discovered: true }) }], + ["agent.describe", { mode: "unary", handle: () => ({ described: true }) }], + ["project.tool-aliases", { mode: "unary", handle: () => ({ aliases: [] }) }], + ...["tool.sources", "tool.list", "tool.execute"].map((name): [string, ExecutorOperation] => [ + name, + { + mode: "stream", + async *handle() { + await pending; + yield { complete: true }; + }, + }, + ]), + ]); + const installation = createExecutorRuntimeInstallation({ + mode: "project-tools", + binding, + artifact, + install: () => { + starts++; + return Promise.resolve({ + operations, + settled: retired.promise, + close: () => { + closes++; + retired.resolve(); + return Promise.resolve(); + }, + }); + }, + }); + return { + installation, + get starts() { + return starts; + }, + get closes() { + return closes; + }, + }; +} + +describe("project tool installation", () => { + it("exposes only discovery and project tools after one authenticated installation", async () => { + const f = fixture(); + try { + for ( + const name of [ + "runtime.prepare", + "agent.stream", + "model.generate", + "state.refresh", + "persistence.append", + ] + ) { + assertEquals(f.installation.operations.has(name), false); + } + await assertRejects(() => call(f.installation.operations, "agent.describe", {})); + assertEquals(f.starts, 0); + assertEquals(await call(f.installation.operations, "runtime.install", request), { + installed: true, + }); + assertEquals(await call(f.installation.operations, "agent.describe", {}), { + described: true, + }); + await assertRejects(() => call(f.installation.operations, "runtime.install", request)); + assertEquals(f.starts, 1); + } finally { + await f.installation.close(); + } + assertEquals(f.closes, 1); + }); + it("rejects credentials, privileged grants, invalid limits and wrong bindings before discovery", async () => { + const f = fixture(); + try { + for ( + const invalid of [ + { ...request, credentials: { token: "synthetic-token" } }, + { ...request, capabilities: { persistence: {} } }, + { ...request, grant: { models: [] } }, + { ...request, maxCalls: 4097 }, + { ...request, maxConcurrent: 33 }, + { ...request, allowedToolNames: ["inspect", "inspect"] }, + { ...request, binding: { ...binding, generation: 2 } }, + { ...request, source: { type: "release", releaseId: "other" } }, + { ...request, context: { ...request.context, projectId: null } }, + ] + ) await assertRejects(() => call(f.installation.operations, "runtime.install", invalid)); + assertEquals(f.starts, 0); + } finally { + await f.installation.close(); + } + }); + it("retains an active project tool operation until original work settles during close", async () => { + const work = Promise.withResolvers(); + const f = fixture(work.promise); + await call(f.installation.operations, "runtime.install", request); + const execute = f.installation.operations.get("tool.execute"); + if (execute?.mode !== "stream") throw new Error("Missing project tool stream"); + const iterator = execute.handle({}, context())[Symbol.asyncIterator](); + const next = iterator.next(); + let closed = false; + const closing = f.installation.close().then(() => { + closed = true; + }); + await Promise.resolve(); + await Promise.resolve(); + assertEquals(closed, false); + work.resolve(); + await next; + await iterator.return?.(); + await closing; + assertEquals(f.closes, 1); + }); +}); diff --git a/src/agent/hosted/executor-project-runtime.test.ts b/src/agent/hosted/executor-project-runtime.test.ts new file mode 100644 index 0000000000..f2a1d2800d --- /dev/null +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -0,0 +1,201 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { defineSchema } from "#veryfront/schemas/index.ts"; +import { tool } from "#veryfront/tool/factory.ts"; +import { agent } from "../factory.ts"; +import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; +import { createExecutorDiscovery } from "./executor-discovery.ts"; +import { createExecutorProjectToolRuntime } from "./executor-project-runtime.ts"; +import { + getExecutorProjectToolInstallSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; + +const binding = { allocationId: "allocation", invocationId: "invocation", generation: 1 }; +const source = { type: "release", releaseId: "synthetic-release" } as const; +const install = () => + parseExecutorInstallation(getExecutorProjectToolInstallSchema(), { + version: 1, + mode: "project-tools", + binding, + source, + root: "project", + owner: { scopeKind: "global", serviceName: "synthetic-service" }, + context: { agentId: "coder", projectId: "synthetic-project", runId: "synthetic-run" }, + allowedToolNames: ["inspect"], + maxCalls: 32, + maxConcurrent: 2, + }); +function fixture(wait?: Promise) { + let cleaned = 0; + let loads = 0; + let calls = 0; + const started = Promise.withResolvers(); + const lifetime = new AbortController(); + const registered = tool({ + id: "inspect", + description: "Inspect an approved argument", + inputSchema: defineSchema((v) => v.object({ query: v.string() }))(), + execute: async (args, context) => { + calls++; + started.resolve(); + await wait; + return { + query: args.query, + agentId: context?.agentId, + projectId: context?.projectId, + runId: context?.runId, + }; + }, + }); + const coder = agent({ + id: "coder", + system: "Synthetic project instructions", + model: "openai/synthetic", + tools: true, + }); + const runtime: ProjectAgentRuntimeDiscovery = { + agents: new Map([["coder", coder]]), + tools: new Map([["inspect", registered]]), + skills: new Map(), + prompts: new Map(), + resources: new Map(), + workflows: new Map(), + tasks: new Map(), + schedules: new Map(), + webhooks: new Map(), + evals: new Map(), + errors: [], + sourceIntegrationPolicy: { schemaVersion: 1, mode: "unrestricted" }, + }; + const discovery = createExecutorDiscovery({ + binding, + source, + signal: lifetime.signal, + projectDir: "/synthetic-project", + backend: { + load: () => { + loads++; + return Promise.resolve(runtime); + }, + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + }, + }); + return { + discovery, + lifetime, + started: started.promise, + get loads() { + return loads; + }, + get cleaned() { + return cleaned; + }, + get calls() { + return calls; + }, + }; +} + +describe("installed project tool runtime", () => { + it("loads the bound project and executes only an explicitly granted project tool", async () => { + const f = fixture(); + const owner = await createExecutorProjectToolRuntime({ + input: install(), + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + try { + assertEquals(owner.operations.has("agent.stream"), false); + assertEquals(owner.operations.has("runtime.prepare"), false); + const operation = owner.operations.get("tool.execute"); + assert(operation?.mode === "stream"); + const context = { binding, signal: f.lifetime.signal, deadline: Date.now() + 10_000 }; + const frames = await Array.fromAsync( + operation.handle({ + sourceId: "project", + toolName: "inspect", + toolCallId: "call", + args: { query: "approved" }, + }, context), + ); + assertEquals(frames, [{ + type: "result", + result: { + query: "approved", + agentId: "coder", + projectId: "synthetic-project", + runId: "synthetic-run", + }, + }]); + const denied = await Array.fromAsync( + operation.handle({ + sourceId: "project", + toolName: "ungranted", + toolCallId: "denied", + args: {}, + }, context), + ); + const failure = denied[0]; + assert(failure !== null && typeof failure === "object" && !Array.isArray(failure)); + assertEquals(failure.type, "failure"); + assertEquals(f.calls, 1); + } finally { + await owner.close(); + } + assertEquals(f.cleaned, 1); + }); + it("cleans up failed or expired discovery without installing tool operations", async () => { + for (const expired of [false, true]) { + const f = fixture(); + const input = install(); + if (!expired) input.context.agentId = "missing"; + await assertRejects(() => + createExecutorProjectToolRuntime({ + input, + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: expired ? Date.now() - 1 : Date.now() + 10_000, + }) + ); + assertEquals(f.loads, expired ? 0 : 1); + assertEquals(f.cleaned, expired ? 0 : 1); + assertEquals(f.calls, 0); + } + }); + it("keeps project resources until a noncooperative tool settles after cancellation", async () => { + const pending = Promise.withResolvers(); + const f = fixture(pending.promise); + const owner = await createExecutorProjectToolRuntime({ + input: install(), + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + const operation = owner.operations.get("tool.execute"); + assert(operation?.mode === "stream"); + const execution = Array.fromAsync( + operation.handle({ + sourceId: "project", + toolName: "inspect", + toolCallId: "call", + args: { query: "approved" }, + }, { binding, signal: f.lifetime.signal, deadline: Date.now() + 10_000 }), + ); + const rejected = assertRejects(() => execution); + await f.started; + const closing = owner.close(); + await Promise.resolve(); + await Promise.resolve(); + assertEquals(f.cleaned, 0); + pending.resolve(); + await rejected; + await closing; + assertEquals(f.cleaned, 1); + }); +}); diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts new file mode 100644 index 0000000000..adb65d90dd --- /dev/null +++ b/src/agent/hosted/executor-project-runtime.ts @@ -0,0 +1,81 @@ +import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { + chainPrivatePromise, + createPrivateDeferred, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; +import type { ExecutorOperation } from "../executor/channel.ts"; +import type { ExecutorDiscovery } from "./executor-discovery.ts"; +import { + getExecutorAgentDescribeResultSchema, + parseDiscoveryData, +} from "./executor-discovery-schema.ts"; +import { createExecutorProjectToolOperations } from "./executor-project-tools.ts"; +import type { ExecutorProjectToolInstall } from "./executor-runtime-install-schema.ts"; +import type { InstalledExecutorRuntime } from "./executor-runtime-install.ts"; +import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; + +/** Called only after the authenticated project-only installation has matched the fixed image. */ +export async function createExecutorProjectToolRuntime(options: { + input: ExecutorProjectToolInstall; + discovery: ExecutorDiscovery; + signal: AbortSignal; + deadline: number; +}): Promise { + const { input, discovery, deadline } = options; + const signal = AbortSignal.any([options.signal, discovery.signal]); + try { + signal.throwIfAborted(); + const describe = discovery.operations.get("agent.describe"); + if (describe?.mode !== "unary") throw new Error("Project discovery unavailable"); + const result = parseDiscoveryData( + getExecutorAgentDescribeResultSchema(), + await chainPrivatePromise(resolvePrivatePromise(), () => + describe.handle( + { agentId: input.context.agentId }, + { binding: input.binding, signal, deadline }, + )), + true, + ); + signal.throwIfAborted(); + if ( + !result.ok || result.value.definition.id !== input.context.agentId || + verifyHostedRuntimeSourceBinding(input.source, result.value.source) !== undefined + ) { + throw new Error("Project discovery did not match installation"); + } + const tools = createExecutorProjectToolOperations({ + scope: { binding: input.binding, signal, assertActive: () => signal.throwIfAborted() }, + context: input.context, + tools: discovery.getRuntime().tools, + allowedToolNames: new Set(input.allowedToolNames), + maxCalls: input.maxCalls, + maxConcurrent: input.maxConcurrent, + }); + const operations = createPrivateMap(); + for (const [name, operation] of discovery.operations) operations.set(name, operation); + for (const [name, operation] of tools) { + if (operation.mode === "unary") operations.set(name, operation); + else {operations.set(name, { + mode: "stream", + async *handle(value, context) { + const retained = createPrivateDeferred(); + discovery.retainRuntimeTask(retained.promise); + try { + yield* operation.handle(value, context); + } finally { + retained.resolve(); + } + }, + });} + } + return { + operations, + close: () => discovery.close(), + settled: discovery.settled, + }; + } catch (error) { + await discovery.close(); + throw error; + } +} diff --git a/src/agent/hosted/executor-runtime-entrypoint.ts b/src/agent/hosted/executor-runtime-entrypoint.ts index de4fdbd935..6a5b0a2d1e 100644 --- a/src/agent/hosted/executor-runtime-entrypoint.ts +++ b/src/agent/hosted/executor-runtime-entrypoint.ts @@ -59,6 +59,8 @@ async function readFixedArtifact() { */ export async function startExecutorRuntimeEntrypoint( options: Pick & { + /** Trusted image profile, fixed before project discovery. */ + mode?: "runtime" | "project-tools"; /** Trusted image/test boundary, never a channel field or environment path. */ readArtifact?: () => Promise<{ manifest: ExecutorArtifactManifest; projectDir: string }>; } = {}, @@ -77,48 +79,76 @@ export async function startExecutorRuntimeEntrypoint( signal.throwIfAborted(); const channel = Promise.withResolvers(); void channel.promise.catch(() => {}); - const installation = createExecutorRuntimeInstallation({ - binding, - artifact: artifact.manifest, - signal, - async install(input, runtimeSignal) { - // No project discovery, runtime factories or capability construction is - // evaluated until the authenticated one-shot installation has passed. - const { createExecutorRuntimeFacades } = await import("./executor-runtime-facades.ts"); - const facades = await createExecutorRuntimeFacades({ - input, - channel: await channel.promise, - signal: runtimeSignal, - }); - let discovery: import("./executor-discovery.ts").ExecutorDiscovery | undefined; - try { - runtimeSignal.throwIfAborted(); + const installation = options.mode === "project-tools" + ? createExecutorRuntimeInstallation({ + mode: "project-tools", + binding, + artifact: artifact.manifest, + signal, + async install(input, runtimeSignal, context) { + // Project-only installation never constructs model or host capability proxies. const { createExecutorDiscovery } = await import("./executor-discovery.ts"); - discovery = createExecutorDiscovery({ + const { createExecutorProjectToolRuntime } = await import("./executor-project-runtime.ts"); + runtimeSignal.throwIfAborted(); + const discovery = createExecutorDiscovery({ binding, source: input.source, projectDir: artifact.projectDir, - defaultAgentId: input.grant.agentId, + defaultAgentId: input.context.agentId, signal: runtimeSignal, }); - const { createExecutorRuntimePreparation } = await import("./executor-runtime-prepare.ts"); - runtimeSignal.throwIfAborted(); - return createExecutorRuntimePreparation({ - binding, - source: input.source, + return await createExecutorProjectToolRuntime({ + input, discovery, - facades, - grant: { - ...input.grant, - models: new Map(input.grant.models.map(({ id, ...policy }) => [id, policy])), - }, + signal: runtimeSignal, + deadline: context.deadline, }); - } catch (error) { - await Promise.allSettled([facades.cleanup(), discovery?.close()]); - throw error; - } - }, - }); + }, + }) + : createExecutorRuntimeInstallation({ + binding, + artifact: artifact.manifest, + signal, + async install(input, runtimeSignal) { + // No project discovery, runtime factories or capability construction is + // evaluated until the authenticated one-shot installation has passed. + const { createExecutorRuntimeFacades } = await import("./executor-runtime-facades.ts"); + const facades = await createExecutorRuntimeFacades({ + input, + channel: await channel.promise, + signal: runtimeSignal, + }); + let discovery: import("./executor-discovery.ts").ExecutorDiscovery | undefined; + try { + runtimeSignal.throwIfAborted(); + const { createExecutorDiscovery } = await import("./executor-discovery.ts"); + discovery = createExecutorDiscovery({ + binding, + source: input.source, + projectDir: artifact.projectDir, + defaultAgentId: input.grant.agentId, + signal: runtimeSignal, + }); + const { createExecutorRuntimePreparation } = await import( + "./executor-runtime-prepare.ts" + ); + runtimeSignal.throwIfAborted(); + return createExecutorRuntimePreparation({ + binding, + source: input.source, + discovery, + facades, + grant: { + ...input.grant, + models: new Map(input.grant.models.map(({ id, ...policy }) => [id, policy])), + }, + }); + } catch (error) { + await Promise.allSettled([facades.cleanup(), discovery?.close()]); + throw error; + } + }, + }); try { const bootstrap = await startExecutorNodeBootstrap({ ...options, diff --git a/src/agent/hosted/executor-runtime-install-schema.ts b/src/agent/hosted/executor-runtime-install-schema.ts index 88d56db29b..2bc41767cb 100644 --- a/src/agent/hosted/executor-runtime-install-schema.ts +++ b/src/agent/hosted/executor-runtime-install-schema.ts @@ -9,7 +9,7 @@ import { import { getExecutorRuntimeGrantDataSchema } from "./executor-runtime-prepare-schema.ts"; import { getExecutorPersistenceCapabilityIdsSchema } from "./executor-persistence-schema.ts"; import { getExecutorDiscoveryIdSchema } from "./executor-discovery-schema.ts"; -import { getExecutorToolIdSchema } from "./executor-tool-schema.ts"; +import { EXECUTOR_TOOL_LIMITS, getExecutorToolIdSchema } from "./executor-tool-schema.ts"; function artifactShape(v: SchemaValidator) { return { @@ -79,6 +79,31 @@ export const getExecutorRuntimeInstallSchema = defineSchema((v) => }, "Missing or ambiguous executor installation authority") ); +/** Project-only installation. Host capabilities and private runtime state stay on the broker. */ +export const getExecutorProjectToolInstallSchema = defineSchema((v) => + v.object({ + ...artifactShape(v), + mode: v.literal("project-tools"), + binding: getExecutorBindingSchema(), + context: v.object({ + agentId: getExecutorDiscoveryIdSchema(), + projectId: getExecutorDiscoveryIdSchema(), + runId: getExecutorDiscoveryIdSchema(), + }).strict(), + allowedToolNames: v.array(getExecutorToolIdSchema()).max( + EXECUTOR_TOOL_LIMITS.maxToolsPerSource, + ), + maxCalls: v.number().int().min(1).max(4096), + maxConcurrent: v.number().int().min(1).max(32), + }).strict().refine( + (input) => new Set(input.allowedToolNames).size === input.allowedToolNames.length, + "Duplicate project tool grant", + ) +); +export type ExecutorProjectToolInstall = InferSchema< + ReturnType +>; + export type ExecutorArtifactManifest = InferSchema< ReturnType >; diff --git a/src/agent/hosted/executor-runtime-install.ts b/src/agent/hosted/executor-runtime-install.ts index 30274e4568..b2ac3ee0a0 100644 --- a/src/agent/hosted/executor-runtime-install.ts +++ b/src/agent/hosted/executor-runtime-install.ts @@ -4,8 +4,10 @@ import { sameHostedExecutorOwner } from "./executor-session-schema.ts"; import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; import { type ExecutorArtifactManifest, + type ExecutorProjectToolInstall, type ExecutorRuntimeInstall, getExecutorArtifactManifestSchema, + getExecutorProjectToolInstallSchema, getExecutorRuntimeInstallSchema, parseExecutorInstallation, } from "./executor-runtime-install-schema.ts"; @@ -16,24 +18,71 @@ export interface InstalledExecutorRuntime { readonly settled: Promise; } -const operationModes = { +const runtimeOperationModes = { "discovery.describe": "unary", "agent.describe": "unary", "runtime.prepare": "unary", "agent.stream": "stream", } as const; +const projectToolOperationModes = { + "discovery.describe": "unary", + "agent.describe": "unary", + "project.tool-aliases": "unary", + "tool.sources": "stream", + "tool.list": "stream", + "tool.execute": "stream", +} as const; + +type InstallationIdentity = { + binding: ExecutorBinding; + artifact: ExecutorArtifactManifest; + signal?: AbortSignal; +}; +type InstallationOptions = + & InstallationIdentity + & ({ + mode?: "runtime"; + install( + input: ExecutorRuntimeInstall, + signal: AbortSignal, + context: ExecutorOperationContext, + ): Promise; + } | { + mode: "project-tools"; + install( + input: ExecutorProjectToolInstall, + signal: AbortSignal, + context: ExecutorOperationContext, + ): Promise; + }); + /** * Executor-only installation gate. Register its fixed map before starting the * authenticated bootstrap; project discovery belongs exclusively in install(). * The broker remains authoritative for grants and operation phases. */ -export function createExecutorRuntimeInstallation(options: { - binding: ExecutorBinding; - artifact: ExecutorArtifactManifest; - signal?: AbortSignal; - install(input: ExecutorRuntimeInstall, signal: AbortSignal): Promise; -}) { +export function createExecutorRuntimeInstallation(options: InstallationOptions) { + const operationModes = options.mode === "project-tools" + ? projectToolOperationModes + : runtimeOperationModes; + const prepareInstallation = (() => { + if (options.mode === "project-tools") { + const install = options.install.bind(options); + return (value: unknown, context: ExecutorOperationContext) => { + const input = parseExecutorInstallation(getExecutorProjectToolInstallSchema(), value); + return { input, start: (signal: AbortSignal) => install(input, signal, context) }; + }; + } + if (options.mode !== undefined && options.mode !== "runtime") { + throw new TypeError("Invalid executor installation profile"); + } + const install = options.install.bind(options); + return (value: unknown, context: ExecutorOperationContext) => { + const input = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), value); + return { input, start: (signal: AbortSignal) => install(input, signal, context) }; + }; + })(); const binding = parseExecutorInstallation(getExecutorBindingSchema(), options.binding); const artifact = parseExecutorInstallation(getExecutorArtifactManifestSchema(), options.artifact); const lifetime = new AbortController(); @@ -103,7 +152,7 @@ export function createExecutorRuntimeInstallation(options: { async handle(value, context) { assertActive(context); if (phase !== "empty") throw new Error("Executor runtime already installed"); - const input = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), value); + const { input, start } = prepareInstallation(value, context); if ( !sameBinding(input.binding) || !sameHostedExecutorOwner(input.owner, artifact.owner) || verifyHostedRuntimeSourceBinding(artifact.source, input.source) !== undefined || @@ -113,7 +162,7 @@ export function createExecutorRuntimeInstallation(options: { context.signal.addEventListener("abort", abort, { once: true }); setup = Promise.resolve().then(() => { assertActive(context); - return options.install(input, lifetime.signal); + return start(lifetime.signal); }); try { const loaded = await setup; diff --git a/tests/fixtures/executor-runtime-process.ts b/tests/fixtures/executor-runtime-process.ts index 66deb496d1..7443584ea1 100644 --- a/tests/fixtures/executor-runtime-process.ts +++ b/tests/fixtures/executor-runtime-process.ts @@ -10,6 +10,7 @@ await initializeExecutorRuntimeContracts(); // Synthetic allocation key arrives on a private pipe; no broker environment or // HTTP data is inherited by this executor process. const executor = await startExecutorRuntimeEntrypoint({ + mode: process.argv[3] === "project-tools" ? "project-tools" : "runtime", readKey: () => Promise.resolve(new Uint8Array(readFileSync(0))), readArtifact: () => Promise.resolve({ diff --git a/tests/integration/agent/executor-runtime-entrypoint.fixture.ts b/tests/integration/agent/executor-runtime-entrypoint.fixture.ts index 8ac94c8923..2ca3ed3c7f 100644 --- a/tests/integration/agent/executor-runtime-entrypoint.fixture.ts +++ b/tests/integration/agent/executor-runtime-entrypoint.fixture.ts @@ -13,6 +13,7 @@ import { createExecutorChannel } from "#veryfront/agent/executor/channel.ts"; import { connectExecutorTransport } from "#veryfront/agent/hosted/executor-node-transport.ts"; import { createExecutorModelBroker } from "#veryfront/agent/hosted/executor-model-bridge.ts"; import { getExecutorDiscoveryResultSchema } from "#veryfront/agent/hosted/executor-discovery-schema.ts"; +import { createExecutorProjectToolSource } from "#veryfront/agent/hosted/executor-project-tools.ts"; import { startExecutorRuntimeEntrypoint } from "#veryfront/agent/hosted/executor-runtime-entrypoint.ts"; const root = new URL("../../../", import.meta.url); @@ -56,144 +57,193 @@ export function registerExecutorRuntimeEntrypointTests(): void { await executor?.close(); } }); - it("loads a real project only in the executor after the fixed installation operation", { - timeout: 45_000, - }, async () => { - const dir = await mkdtemp(join(tmpdir(), "vf-managed-executor-")); - const marker = join(dir, "loaded.json"); - await mkdir(join(dir, "crew")); - await writeFile( - join(dir, "veryfront.config.ts"), - `import { writeFileSync } from "node:fs"; import process from "node:process"; writeFileSync(${ - JSON.stringify(marker) - }, JSON.stringify({pid:process.pid})); export default { ai: { agents: { discovery: { paths: ["crew"] } } } };`, - ); - await writeFile( - join(dir, "crew", "writer.md"), - "---\nname: Writer\n---\nSynthetic instructions.\n", - ); - const binding = { allocationId: randomUUID(), generation: 1, invocationId: randomUUID() }; - const key = randomBytes(32); - const child = spawn(process.execPath, [ - "--import", - resolver, - fileURLToPath(new URL("tests/fixtures/executor-runtime-process.ts", root)), - dir, - ], { - cwd: fileURLToPath(root), - env: { - PATH: "/usr/local/bin:/usr/bin:/bin", - VERYFRONT_EXECUTOR_ALLOCATION_ID: binding.allocationId, - VERYFRONT_EXECUTOR_INVOCATION_ID: binding.invocationId, - VERYFRONT_EXECUTOR_GENERATION: "1", - VERYFRONT_EXECUTOR_ACTIVE_DEADLINE_SECONDS: "40", - VERYFRONT_EXECUTOR_HARD_DEADLINE_AT: String(Date.now() + 40_000), - PORT: "8081", - }, - stdio: ["pipe", "pipe", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (chunk) => stderr += chunk); - const exited = new Promise((resolve, reject) => { - child.once("error", reject); - child.once("close", resolve); - }); - void exited.catch(() => {}); - const ready = new Promise<{ pid: number; port: number }>((resolve, reject) => { - let output = ""; - child.stdout.on("data", (chunk) => { - output += chunk; - if (output.includes("\n")) { - try { - resolve(JSON.parse(output.split("\n")[0]!)); - } catch { - reject(new Error("Invalid executor readiness")); - } - } - }); - void exited.then( - () => reject(new Error(`Executor exited before readiness: ${stderr}`)), - reject, + for (const profile of ["runtime", "project-tools"] as const) { + it(`loads a real project only after fixed ${profile} installation`, { + timeout: 45_000, + }, async () => { + const dir = await mkdtemp(join(tmpdir(), "vf-managed-executor-")); + const marker = join(dir, "loaded.json"); + await mkdir(join(dir, "crew")); + await writeFile( + join(dir, "veryfront.config.ts"), + `import { writeFileSync } from "node:fs"; import process from "node:process"; writeFileSync(${ + JSON.stringify(marker) + }, JSON.stringify({pid:process.pid})); export default { ai: { agents: { discovery: { paths: ["crew"] } } } };`, ); - }); - child.stdin.end(key); - let channel: ReturnType | undefined; - const timer = setTimeout(() => child.kill(), 40_000); - try { - const endpoint = await ready; - assert(endpoint.pid !== process.pid); - assertEquals(existsSync(marker), false); - const transport = await connectExecutorTransport({ - podIp: "127.0.0.1", - port: endpoint.port, - key, - binding, - timeoutMs: 30_000, + await writeFile( + join(dir, "crew", "writer.md"), + "---\nname: Writer\n---\nSynthetic instructions.\n", + ); + if (profile === "project-tools") { + await mkdir(join(dir, "tools")); + await writeFile( + join(dir, "tools", "inspect.ts"), + 'import { tool } from "veryfront/tool"; import { defineSchema } from "veryfront/schemas"; export default tool({ id: "inspect", description: "Inspect approved data", inputSchema: defineSchema(v => v.object({ query: v.string() }))(), execute: (input, context) => ({ query: input.query, agentId: context.agentId, projectId: context.projectId, runId: context.runId, toolCallId: context.toolCallId }) });', + ); + } + const binding = { allocationId: randomUUID(), generation: 1, invocationId: randomUUID() }; + const key = randomBytes(32); + const child = spawn(process.execPath, [ + "--import", + resolver, + fileURLToPath(new URL("tests/fixtures/executor-runtime-process.ts", root)), + dir, + profile, + ], { + cwd: fileURLToPath(root), + env: { + PATH: "/usr/local/bin:/usr/bin:/bin", + VERYFRONT_EXECUTOR_ALLOCATION_ID: binding.allocationId, + VERYFRONT_EXECUTOR_INVOCATION_ID: binding.invocationId, + VERYFRONT_EXECUTOR_GENERATION: "1", + VERYFRONT_EXECUTOR_ACTIVE_DEADLINE_SECONDS: "40", + VERYFRONT_EXECUTOR_HARD_DEADLINE_AT: String(Date.now() + 40_000), + PORT: "8081", + }, + stdio: ["pipe", "pipe", "pipe"], }); - const modelId = "veryfront-cloud/openai/synthetic-model"; - channel = createExecutorChannel({ - binding, - transport, - operations: createExecutorModelBroker({ - allowedModelIds: new Set([modelId]), - resolveModelRuntime: () => ({ - provider: "openai", - modelId: "synthetic-model", - specificationVersion: "v3", - doGenerate: () => { - throw new Error("Unexpected model call"); - }, - doStream: () => { - throw new Error("Unexpected model call"); - }, - }), - }), + let stderr = ""; + child.stderr.on("data", (chunk) => stderr += chunk); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); }); - await channel.ready; - await assertRejects(() => channel!.request("discovery.describe", {})); - assertEquals(existsSync(marker), false); - const input = { - version: 1, - binding, - root: "project", - owner: { scopeKind: "global", serviceName: "veryfront-agent" }, - source: { type: "release", releaseId: "synthetic-release" }, - grant: { + void exited.catch(() => {}); + const ready = new Promise<{ pid: number; port: number }>((resolve, reject) => { + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + if (output.includes("\n")) { + try { + resolve(JSON.parse(output.split("\n")[0]!)); + } catch { + reject(new Error("Invalid executor readiness")); + } + } + }); + void exited.then( + () => reject(new Error(`Executor exited before readiness: ${stderr}`)), + reject, + ); + }); + child.stdin.end(key); + let channel: ReturnType | undefined; + const timer = setTimeout(() => child.kill(), 40_000); + try { + const endpoint = await ready; + assert(endpoint.pid !== process.pid); + assertEquals(existsSync(marker), false); + const transport = await connectExecutorTransport({ + podIp: "127.0.0.1", + port: endpoint.port, + key, + binding, + timeoutMs: 30_000, + }); + const modelId = "veryfront-cloud/openai/synthetic-model"; + channel = createExecutorChannel({ + binding, + transport, + operations: profile === "project-tools" ? new Map() : createExecutorModelBroker({ + allowedModelIds: new Set([modelId]), + resolveModelRuntime: () => ({ + provider: "openai", + modelId: "synthetic-model", + specificationVersion: "v3", + doGenerate: () => { + throw new Error("Unexpected model call"); + }, + doStream: () => { + throw new Error("Unexpected model call"); + }, + }), + }), + }); + await channel.ready; + await assertRejects(() => channel!.request("discovery.describe", {})); + assertEquals(existsSync(marker), false); + const runtimeInput = { + version: 1, + binding, + root: "project", + owner: { scopeKind: "global", serviceName: "veryfront-agent" }, + source: { type: "release", releaseId: "synthetic-release" }, + grant: { + agentId: "writer", + defaultModelId: modelId, + maxSteps: 3, + models: [{ id: modelId, maxOutputTokens: 100, providerToolNames: [] }], + allowedToolNames: [], + hostToolFacadeIds: [], + remoteToolSourceIds: [], + execution: { kind: "ephemeral", projectId: null }, + }, + capabilities: { persistence: {} }, + }; + const projectContext = { agentId: "writer", - defaultModelId: modelId, - maxSteps: 3, - models: [{ id: modelId, maxOutputTokens: 100, providerToolNames: [] }], - allowedToolNames: [], - hostToolFacadeIds: [], - remoteToolSourceIds: [], - execution: { kind: "ephemeral", projectId: null }, - }, - capabilities: { persistence: {} }, - }; - await assertRejects(() => - channel!.request("runtime.install", { - ...input, - source: { type: "release", releaseId: "wrong" }, - }) - ); - assertEquals(existsSync(marker), false); - assertEquals(await channel.request("runtime.install", input), { installed: true }); - const description = getExecutorDiscoveryResultSchema().parse( - await channel.request("discovery.describe", {}, { timeoutMs: 30_000 }), - ); - assert(description.ok, JSON.stringify(description)); - assertEquals(JSON.parse(await readFile(marker, "utf8")).pid, endpoint.pid); - await assertRejects(() => channel!.request("runtime.install", input)); - channel.close(); - await channel.settled; - assertEquals(await exited, 0, stderr); - } finally { - clearTimeout(timer); - channel?.close(); - await channel?.settled; - child.kill(); - await exited.catch(() => {}); - await rm(dir, { recursive: true, force: true }); - } - }); + projectId: "synthetic-project", + runId: "synthetic-run", + }; + const input = profile === "runtime" ? runtimeInput : { + version: 1, + mode: "project-tools", + binding, + root: "project", + owner: runtimeInput.owner, + source: runtimeInput.source, + context: projectContext, + allowedToolNames: ["inspect"], + maxCalls: 32, + maxConcurrent: 2, + }; + await assertRejects(() => + channel!.request("runtime.install", { + ...input, + source: { type: "release", releaseId: "wrong" }, + }) + ); + assertEquals(existsSync(marker), false); + assertEquals(await channel.request("runtime.install", input), { installed: true }); + const description = getExecutorDiscoveryResultSchema().parse( + await channel.request("discovery.describe", {}, { timeoutMs: 30_000 }), + ); + assert(description.ok, JSON.stringify(description)); + assertEquals(JSON.parse(await readFile(marker, "utf8")).pid, endpoint.pid); + await assertRejects(() => channel!.request("runtime.install", input)); + if (profile === "project-tools") { + await assertRejects(() => channel!.request("runtime.prepare", { agentId: "writer" })); + await assertRejects(() => Array.fromAsync(channel!.stream("agent.stream", {}))); + const projectSource = await createExecutorProjectToolSource({ + channel, + signal: channel.signal, + context: projectContext, + allowedToolNames: new Set(["inspect"]), + assertActive() {}, + }); + assertEquals((await projectSource.listTools()).map((tool) => tool.name), ["inspect"]); + assertEquals( + await projectSource.executeTool("inspect", { query: "approved" }, { + toolCallId: "call", + }), + { + query: "approved", + ...projectContext, + toolCallId: "call", + }, + ); + } + channel.close(); + await channel.settled; + assertEquals(await exited, 0, stderr); + } finally { + clearTimeout(timer); + channel?.close(); + await channel?.settled; + child.kill(); + await exited.catch(() => {}); + await rm(dir, { recursive: true, force: true }); + } + }); + } } From e576e6a39a7894b5417360d8a03fb93297f9946e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 15:16:33 +0200 Subject: [PATCH 03/19] test(agent): make native fixture field ordering explicit --- .../agent/fixtures/trusted-project/tools/inspect.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts index 45f1f7792c..3fd2b9c0d2 100644 --- a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts +++ b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts @@ -26,7 +26,10 @@ export default tool({ projectId: context?.projectId, toolCallId: context?.toolCallId, }, - fields: Object.keys(context ?? {}).sort(), + fields: Object.keys(context ?? {}).sort((left, right) => { + if (left < right) return -1; + return left > right ? 1 : 0; + }), observations: observations(), hasParentSecret: Object.hasOwn(process.env, "VF_NATIVE_PARENT_SECRET"), }); From 17db73fe71d02d289875feed038655a7144db742 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 16:45:00 +0200 Subject: [PATCH 04/19] fix(agent): derive project tool run authority from execution kind --- .../hosted/executor-project-tools.test.ts | 66 ++++++++++++++++--- src/agent/hosted/executor-project-tools.ts | 23 +++++-- .../hosted/trusted-runtime-prepare.test.ts | 12 +++- .../fixtures/trusted-project-executor.ts | 6 +- .../fixtures/trusted-runtime-scenario.ts | 16 ++++- 5 files changed, 104 insertions(+), 19 deletions(-) diff --git a/src/agent/hosted/executor-project-tools.test.ts b/src/agent/hosted/executor-project-tools.test.ts index 28723a3bf2..df27e22ab6 100644 --- a/src/agent/hosted/executor-project-tools.test.ts +++ b/src/agent/hosted/executor-project-tools.test.ts @@ -20,6 +20,11 @@ const binding = { invocationId: "synthetic-invocation", }; const fixed = { agentId: "coder", runId: "synthetic-run", projectId: "synthetic-project" }; +const projectContext: ExecutorProjectToolContext = { + agentId: fixed.agentId, + projectId: fixed.projectId, + execution: { kind: "canonical", runId: fixed.runId }, +}; const correlation = { toolCallId: "synthetic-call", progressToken: "synthetic-progress" }; const inputSchema = defineSchema((v) => v.object({ query: v.string() }))(); @@ -66,7 +71,7 @@ function fixture( const registered = tool({ id: "inspect", description: "Inspect a query", inputSchema, execute }); const tools = new Map([["inspect", registered]]); const allowedToolNames = new Set(["inspect"]); - const context = { ...fixed }; + const context = { ...projectContext }; const operations = createExecutorProjectToolOperations({ scope: { binding, signal: lifetime.signal, assertActive }, context, @@ -92,7 +97,7 @@ function fixture( return createExecutorProjectToolSource({ channel: channels.trusted, signal: lifetime.signal, - context: { ...fixed }, + context: { ...projectContext }, allowedToolNames: new Set(["inspect"]), assertActive, ...options, @@ -113,6 +118,7 @@ describe("executor project tools", () => { fields: Object.keys(context).sort(), agentId: context.agentId, runId: context.runId, + runIdBindsToolAuthorization: context.runIdBindsToolAuthorization, projectId: context.projectId, toolCallId: context.toolCallId, progressToken: context.progressToken, @@ -149,10 +155,12 @@ describe("executor project tools", () => { "projectId", "publishDataEvent", "runId", + "runIdBindsToolAuthorization", "toolCallId", ], ...fixed, ...correlation, + runIdBindsToolAuthorization: true, }); assertEquals(executions, 1); for ( @@ -173,6 +181,44 @@ describe("executor project tools", () => { } }); + it("keeps ephemeral project tools unbound to control-plane run authority", async () => { + const context: ExecutorProjectToolContext = { + agentId: fixed.agentId, + projectId: fixed.projectId, + execution: { kind: "ephemeral" }, + }; + let executions = 0; + const f = fixture(async (_args, call) => { + executions++; + assert(call); + return { + hasRunId: Object.hasOwn(call, "runId"), + runIdBindsToolAuthorization: call.runIdBindsToolAuthorization, + projectId: call.projectId, + }; + }, { context }); + try { + const source = await f.source({ context }); + assertEquals(await source.executeTool("inspect", { query: "hello" }, correlation), { + hasRunId: false, + runIdBindsToolAuthorization: false, + projectId: fixed.projectId, + }); + await assertRejects(() => + source.executeTool("inspect", { query: "hello" }, { ...correlation, runId: fixed.runId }) + ); + await assertRejects(() => + source.executeTool("inspect", { query: "hello" }, { + ...correlation, + runIdBindsToolAuthorization: true, + }) + ); + assertEquals(executions, 1); + } finally { + await f.close(); + } + }); + it("rejects conflicting explicit identities and missing or malformed call correlation before dispatch", async () => { let executions = 0; const f = fixture(async () => ++executions); @@ -231,7 +277,7 @@ describe("executor project tools", () => { it("snapshots both construction contexts, grants, tool callbacks and detached descriptors", async () => { const f = fixture(); - const context = { ...fixed }; + const context = { ...projectContext }; const allowedToolNames = new Set(["inspect"]); try { const pending = f.source({ context, allowedToolNames }); @@ -298,11 +344,13 @@ describe("executor project tools", () => { try { for ( const context of [ - { ...fixed, authToken: "" }, - { ...fixed, optionalRequirement: true }, - { ...fixed, agentId: "" }, - { ...fixed, runId: 1 }, - { ...fixed, projectId: "x".repeat(257) }, + { ...projectContext, authToken: "" }, + { ...projectContext, optionalRequirement: true }, + { ...projectContext, agentId: "" }, + { ...projectContext, execution: { kind: "canonical", runId: 1 } }, + { ...projectContext, execution: { kind: "canonical" } }, + { ...projectContext, execution: { kind: "ephemeral", runId: fixed.runId } }, + { ...projectContext, projectId: "x".repeat(257) }, { agentId: "coder", runId: "synthetic-run" }, ] ) { @@ -407,7 +455,7 @@ describe("executor project tools", () => { createExecutorProjectToolSource({ channel: f.trusted, signal: new AbortController().signal, - context: fixed, + context: projectContext, allowedToolNames: new Set(["inspect"]), assertActive() {}, }) diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index 401413bc5d..400d47e69a 100644 --- a/src/agent/hosted/executor-project-tools.ts +++ b/src/agent/hosted/executor-project-tools.ts @@ -31,18 +31,31 @@ export interface ExecutorProjectToolSource extends RemoteToolSource { export interface ExecutorProjectToolContext { agentId: string; - runId: string; projectId: string; + execution: { kind: "canonical"; runId: string } | { kind: "ephemeral" }; } const getContextSchema = defineSchema((v) => v.object({ agentId: getExecutorToolIdSchema(), - runId: getExecutorToolIdSchema(), projectId: getExecutorToolIdSchema(), + execution: v.discriminatedUnion("kind", [ + v.object({ kind: v.literal("canonical"), runId: getExecutorToolIdSchema() }).strict(), + v.object({ kind: v.literal("ephemeral") }).strict(), + ]), }).strict() ); +function captureContext(input: ExecutorProjectToolContext) { + const context = parseExecutorToolData(getContextSchema(), input); + return Object.freeze({ + agentId: context.agentId, + projectId: context.projectId, + runIdBindsToolAuthorization: context.execution.kind === "canonical", + ...(context.execution.kind === "canonical" ? { runId: context.execution.runId } : {}), + }); +} + const getAliasesSchema = defineSchema((v) => v.object({ agentId: getExecutorToolIdSchema(), @@ -98,7 +111,7 @@ function callField( export function createExecutorProjectToolOperations( options: ExecutorProjectToolOperationsOptions, ): ReadonlyMap { - const fixed = Object.freeze(parseExecutorToolData(getContextSchema(), options.context)); + const fixed = captureContext(options.context); const limits = executorToolLimits(options.limits); const allowed = captureNames(options.allowedToolNames, limits); if (options.tools.size > limits.maxTotalTools) { @@ -189,7 +202,7 @@ export function createExecutorProjectToolOperations( export async function createExecutorProjectToolSource( options: ExecutorProjectToolSourceOptions, ): Promise { - const fixed = Object.freeze(parseExecutorToolData(getContextSchema(), options.context)); + const fixed = captureContext(options.context); const limits = executorToolLimits(options.limits); const allowed = captureNames(options.allowedToolNames, limits); const { channel, signal, assertActive } = options; @@ -200,7 +213,7 @@ export async function createExecutorProjectToolSource( }; const projectContext = (context?: ToolExecutionContext): ToolExecutionContext => { check(); - for (const key of ["agentId", "runId", "projectId"] as const) { + for (const key of ["agentId", "runId", "projectId", "runIdBindsToolAuthorization"] as const) { const requested = callField(context, key); if (requested !== undefined && requested !== fixed[key]) { throw new TypeError("Project tool call identity mismatch"); diff --git a/src/agent/hosted/trusted-runtime-prepare.test.ts b/src/agent/hosted/trusted-runtime-prepare.test.ts index 42c6e426a0..04083064a3 100644 --- a/src/agent/hosted/trusted-runtime-prepare.test.ts +++ b/src/agent/hosted/trusted-runtime-prepare.test.ts @@ -106,7 +106,11 @@ async function fixture(options: { } const operations = new Map(createExecutorProjectToolOperations({ scope: { binding, signal: lifetime.signal, assertActive() {} }, - context, + context: { + agentId: context.agentId, + projectId: context.projectId, + execution: { kind: "canonical", runId: context.runId }, + }, tools: new Map([[toolName, registered]]), allowedToolNames: new Set([toolName]), maxCalls: 32, @@ -150,7 +154,11 @@ async function fixture(options: { const projectTools = await createExecutorProjectToolSource({ channel: trusted, signal: lifetime.signal, - context, + context: { + agentId: context.agentId, + projectId: context.projectId, + execution: { kind: "canonical", runId: context.runId }, + }, allowedToolNames: new Set([toolName]), assertActive() {}, }); diff --git a/tests/integration/agent/fixtures/trusted-project-executor.ts b/tests/integration/agent/fixtures/trusted-project-executor.ts index 4bc7fd99d5..e87e77bb11 100644 --- a/tests/integration/agent/fixtures/trusted-project-executor.ts +++ b/tests/integration/agent/fixtures/trusted-project-executor.ts @@ -44,7 +44,11 @@ const operations = new Map(discovery.operations); for ( const [name, operation] of createExecutorProjectToolOperations({ scope: { binding, signal, assertActive() {} }, - context, + context: { + agentId: context.agentId, + projectId: context.projectId, + execution: { kind: "canonical", runId: context.runId }, + }, tools: runtime.tools, allowedToolNames: new Set(["inspect"]), maxCalls: 32, diff --git a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts index 4ed81c57fc..b660e08e7a 100644 --- a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts +++ b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts @@ -78,7 +78,11 @@ export async function runNativeTrustedScenario( const source = await createExecutorProjectToolSource({ channel, signal, - context, + context: { + agentId: context.agentId, + projectId: context.projectId, + execution: { kind: "canonical", runId: context.runId }, + }, allowedToolNames: new Set(["inspect"]), assertActive() {}, }); @@ -248,7 +252,15 @@ export async function runNativeTrustedScenario( assertEquals(results, [{ query: "authorized query", context: { ...context, toolCallId: "synthetic-call" }, - fields: ["abortSignal", "agentId", "projectId", "publishDataEvent", "runId", "toolCallId"], + fields: [ + "abortSignal", + "agentId", + "projectId", + "publishDataEvent", + "runId", + "runIdBindsToolAuthorization", + "toolCallId", + ], observations: [], hasParentSecret: false, }]); From 3ec1daf750291bed66af6dcd005414261554eb99 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 16:47:59 +0200 Subject: [PATCH 05/19] fix(agent): bind installed project tools to canonical execution --- src/agent/hosted/executor-project-runtime.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts index adb65d90dd..8910b7a14c 100644 --- a/src/agent/hosted/executor-project-runtime.ts +++ b/src/agent/hosted/executor-project-runtime.ts @@ -46,7 +46,11 @@ export async function createExecutorProjectToolRuntime(options: { } const tools = createExecutorProjectToolOperations({ scope: { binding: input.binding, signal, assertActive: () => signal.throwIfAborted() }, - context: input.context, + context: { + agentId: input.context.agentId, + projectId: input.context.projectId, + execution: { kind: "canonical", runId: input.context.runId }, + }, tools: discovery.getRuntime().tools, allowedToolNames: new Set(input.allowedToolNames), maxCalls: input.maxCalls, From 40743f993a837338a358fd0b150bda13687e51f8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 17:03:23 +0200 Subject: [PATCH 06/19] test(agent): use explicit execution kind in native project fixture --- .../agent/executor-runtime-entrypoint.fixture.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/agent/executor-runtime-entrypoint.fixture.ts b/tests/integration/agent/executor-runtime-entrypoint.fixture.ts index 2ca3ed3c7f..b8002e60ff 100644 --- a/tests/integration/agent/executor-runtime-entrypoint.fixture.ts +++ b/tests/integration/agent/executor-runtime-entrypoint.fixture.ts @@ -217,7 +217,11 @@ export function registerExecutorRuntimeEntrypointTests(): void { const projectSource = await createExecutorProjectToolSource({ channel, signal: channel.signal, - context: projectContext, + context: { + agentId: projectContext.agentId, + projectId: projectContext.projectId, + execution: { kind: "canonical", runId: projectContext.runId }, + }, allowedToolNames: new Set(["inspect"]), assertActive() {}, }); From 6f325eb2399d1a7c1469b08c9171f5aea3ce7135 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 17:33:46 +0200 Subject: [PATCH 07/19] fix(agent): preserve executor tool authority under patched collections --- src/agent/hosted/executor-project-tools.ts | 104 ++++++++------ .../hosted/executor-runtime-install.test.ts | 26 ++++ src/agent/hosted/executor-runtime-install.ts | 70 +++++++--- src/agent/hosted/executor-tool-bridge.ts | 127 +++++++++++------- src/agent/hosted/executor-tool-schema.test.ts | 10 ++ src/agent/hosted/executor-tool-schema.ts | 69 ++++++---- src/schemas/json-value.ts | 17 ++- src/security/private-collection-copy.test.ts | 33 +++++ src/security/private-map.ts | 15 +++ src/security/private-set.ts | 13 ++ .../agent/fixtures/trusted-project/probe.ts | 9 ++ .../fixtures/trusted-project/tools/denied.ts | 13 ++ .../fixtures/trusted-project/tools/inspect.ts | 1 + .../fixtures/trusted-runtime-scenario.ts | 28 +++- .../agent/trusted-runtime-preparation.test.ts | 11 +- 15 files changed, 409 insertions(+), 137 deletions(-) create mode 100644 src/security/private-collection-copy.test.ts create mode 100644 tests/integration/agent/fixtures/trusted-project/tools/denied.ts diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index 400d47e69a..f300f540cc 100644 --- a/src/agent/hosted/executor-project-tools.ts +++ b/src/agent/hosted/executor-project-tools.ts @@ -1,4 +1,13 @@ import { defineSchema } from "#veryfront/schemas/index.ts"; +import { copyPrivateSet, createPrivateSet } from "#veryfront/security/private-set.ts"; +import { copyPrivateMap, createPrivateMap } from "#veryfront/security/private-map.ts"; +import { + filterPrivateArray, + mapPrivateArray, + pushPrivateArray, + somePrivateArray, +} from "#veryfront/security/private-array.ts"; +import { chainPrivatePromise, resolvePrivatePromise } from "#veryfront/security/private-promise.ts"; import type { ExecutorChannel, ExecutorOperation } from "#veryfront/agent/executor/channel.ts"; import { type ExecutorBinding, @@ -8,7 +17,10 @@ import type { RemoteToolSource, Tool, ToolExecutionContext } from "#veryfront/to import { isToolVisibleTo } from "#veryfront/tool/executor.ts"; import { toolToProviderDefinition } from "#veryfront/tool/registry.ts"; import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; -import { createExecutorToolBroker } from "#veryfront/agent/hosted/executor-tool-bridge.ts"; +import { + createExecutorToolBroker, + type ExecutorToolCapability, +} from "#veryfront/agent/hosted/executor-tool-bridge.ts"; import { createExecutorRemoteToolSources } from "#veryfront/agent/hosted/executor-tool-remote-facade.ts"; import { EXECUTOR_TOOL_LIMITS, @@ -24,6 +36,10 @@ import { export const EXECUTOR_PROJECT_TOOL_SOURCE_ID = "project"; const TOOL_ALIASES_OPERATION = "project.tool-aliases"; +const apply = Reflect.apply; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; export interface ExecutorProjectToolSource extends RemoteToolSource { readonly aliases: readonly { readonly name: string; readonly shortName: string }[]; @@ -48,7 +64,7 @@ const getContextSchema = defineSchema((v) => function captureContext(input: ExecutorProjectToolContext) { const context = parseExecutorToolData(getContextSchema(), input); - return Object.freeze({ + return freeze({ agentId: context.agentId, projectId: context.projectId, runIdBindsToolAuthorization: context.execution.kind === "canonical", @@ -85,13 +101,11 @@ export interface ExecutorProjectToolSourceOptions { } function captureNames(names: ReadonlySet, limits: ExecutorToolLimits): Set { - const result = new Set(); - for (const name of names) { - if (result.size >= limits.maxToolsPerSource) { - throw new TypeError("Project tool allowlist exceeds its limit"); - } - result.add(parseExecutorToolData(getExecutorToolIdSchema(), name)); + const result = copyPrivateSet(names, limits.maxToolsPerSource); + if (result.size > limits.maxToolsPerSource) { + throw new TypeError("Project tool allowlist exceeds its limit"); } + for (const name of result) parseExecutorToolData(getExecutorToolIdSchema(), name); return result; } @@ -101,9 +115,9 @@ function callField( key: K, ): ToolExecutionContext[K] { if (context === undefined) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(context, key); + const descriptor = getOwnPropertyDescriptor(context, key); if (!descriptor) return undefined; - if (!Object.hasOwn(descriptor, "value")) throw new TypeError("Invalid project tool call context"); + if (!hasOwn(descriptor, "value")) throw new TypeError("Invalid project tool call context"); return descriptor.value; } @@ -114,28 +128,31 @@ export function createExecutorProjectToolOperations( const fixed = captureContext(options.context); const limits = executorToolLimits(options.limits); const allowed = captureNames(options.allowedToolNames, limits); - if (options.tools.size > limits.maxTotalTools) { + const tools = copyPrivateMap(options.tools, limits.maxTotalTools); + if (tools.size > limits.maxTotalTools) { throw new TypeError("Project tool catalog exceeds its limit"); } - const catalog = new Map; execute: Tool["execute"]; }>(); const aliases: { name: string; shortName: string }[] = []; - for (const [name, registered] of options.tools) { + for (const [name, registered] of tools) { if ( !allowed.has(name) || isSkillInfrastructureToolId(name) || !isToolVisibleTo(registered, { agentId: fixed.agentId }) ) continue; if (typeof registered.execute !== "function") throw new TypeError("Invalid project tool"); - const execute = registered.execute.bind(registered); + const callback = registered.execute; + const execute: Tool["execute"] = (args, context) => + apply(callback, registered, [args, context]); const definition = executorToolDefinition({ ...toolToProviderDefinition(registered), name, }, limits); catalog.set(name, { definition, execute }); if (registered.ownerAgentId === fixed.agentId && registered.shortName !== undefined) { - aliases.push({ + pushPrivateArray(aliases, { name, shortName: parseExecutorToolData(getExecutorToolIdSchema(), registered.shortName), }); @@ -144,8 +161,11 @@ export function createExecutorProjectToolOperations( const source: RemoteToolSource = { id: EXECUTOR_PROJECT_TOOL_SOURCE_ID, listTools: () => - Promise.resolve( - [...catalog.values()].map(({ definition }) => executorToolDefinition(definition, limits)), + chainPrivatePromise( + resolvePrivatePromise(), + () => + mapPrivateArray([...catalog.values()], ({ definition }) => + executorToolDefinition(definition, limits)), ), async executeTool(name, args, context) { const selected = catalog.get(name); @@ -161,20 +181,23 @@ export function createExecutorProjectToolOperations( }); }, }; - const operations = new Map(createExecutorToolBroker({ + const sources = createPrivateMap(); + sources.set(source.id, { + source, + allowedToolNames: createPrivateSet(catalog.keys()), + context: fixed, + }); + const operations = copyPrivateMap(createExecutorToolBroker({ scope: options.scope, - sources: new Map([[source.id, { - source, - allowedToolNames: new Set(catalog.keys()), - context: fixed, - }]]), + sources, maxCalls: options.maxCalls, maxConcurrent: options.maxConcurrent, limits, })); const binding = parseExecutorToolData(getExecutorBindingSchema(), options.scope.binding); const signal = options.scope.signal; - const assertActive = options.scope.assertActive.bind(options.scope); + const assertScope = options.scope.assertActive; + const assertActive = () => apply(assertScope, options.scope, []); let described = false; operations.set(TOOL_ALIASES_OPERATION, { mode: "unary", @@ -236,7 +259,7 @@ export async function createExecutorProjectToolSource( ...(publish === undefined ? {} : { publishDataEvent: async (event) => { check(); - await publish.call(context, event); + await apply(publish, context, [event]); check(); }, }), @@ -259,31 +282,36 @@ export async function createExecutorProjectToolSource( if (metadata.agentId !== fixed.agentId) { throw new TypeError("Project tool metadata owner mismatch"); } - const catalog = new Map( - definitions.filter((definition) => allowed.has(definition.name)).map( - (definition) => [definition.name, definition] as const, - ), - ); - const aliases = new Map(); - for (const entry of metadata.aliases) { + const catalog = createPrivateMap(); + for (let index = 0; index < definitions.length; index++) { + const definition = definitions[index]!; + if (allowed.has(definition.name)) catalog.set(definition.name, definition); + } + const aliases = createPrivateMap(); + for (let index = 0; index < metadata.aliases.length; index++) { + const entry = metadata.aliases[index]!; if ( - !definitions.some((definition) => definition.name === entry.name) || + !somePrivateArray(definitions, (definition) => definition.name === entry.name) || aliases.has(entry.shortName) ) { throw new TypeError("Invalid project tool aliases"); } aliases.set(entry.shortName, entry.name); } - return Object.freeze({ + return freeze({ id: EXECUTOR_PROJECT_TOOL_SOURCE_ID, - aliases: Object.freeze( - [...aliases].filter(([, name]) => catalog.has(name)).map(([shortName, name]) => - Object.freeze({ name, shortName }) + aliases: freeze( + mapPrivateArray( + filterPrivateArray([...aliases], (entry) => catalog.has(entry[1])), + (entry) => freeze({ name: entry[1], shortName: entry[0] }), ), ), async listTools(context?: ToolExecutionContext) { projectContext(context); - return [...catalog.values()].map((definition) => executorToolDefinition(definition, limits)); + return mapPrivateArray( + [...catalog.values()], + (definition) => executorToolDefinition(definition, limits), + ); }, async executeTool(name: string, args: Record, context?: ToolExecutionContext) { const call = projectContext(context); diff --git a/src/agent/hosted/executor-runtime-install.test.ts b/src/agent/hosted/executor-runtime-install.test.ts index 5a9c4ec42f..4555821c8d 100644 --- a/src/agent/hosted/executor-runtime-install.test.ts +++ b/src/agent/hosted/executor-runtime-install.test.ts @@ -73,6 +73,32 @@ function runtime() { } describe("executor runtime installation", () => { + it("observes retirement rejection while original cleanup remains pending", async () => { + const cleanup = Promise.withResolvers(); + const retirement = Promise.withResolvers(); + const installation = createExecutorRuntimeInstallation({ + binding, + artifact, + install: () => + Promise.resolve({ + ...runtime(), + close: () => cleanup.promise, + settled: retirement.promise, + }), + }); + await call(installation.operations, "runtime.install", request()); + const closing = assertRejects(() => installation.close(), Error, "cleanup failed"); + try { + retirement.reject(new Error("Synthetic retirement failure")); + // Let unhandled-rejection reporting run while the independent cleanup is held. + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + cleanup.resolve(); + await closing; + await assertRejects(() => installation.settled); + } + }); + it("accepts host aliases only for the installed owner, source, and canonical tool", () => { const alias = { sourceId: "host", diff --git a/src/agent/hosted/executor-runtime-install.ts b/src/agent/hosted/executor-runtime-install.ts index 30274e4568..093d620a6e 100644 --- a/src/agent/hosted/executor-runtime-install.ts +++ b/src/agent/hosted/executor-runtime-install.ts @@ -1,4 +1,12 @@ import type { ExecutorOperation, ExecutorOperationContext } from "../executor/channel.ts"; +import { copyPrivateMap, createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { + chainPrivatePromise, + createPrivateDeferred, + observePrivatePromise, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; import { type ExecutorBinding, getExecutorBindingSchema } from "../executor/protocol.ts"; import { sameHostedExecutorOwner } from "./executor-session-schema.ts"; import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; @@ -22,6 +30,8 @@ const operationModes = { "runtime.prepare": "unary", "agent.stream": "stream", } as const; +const operationEntries = Object.entries(operationModes); +const apply = Reflect.apply; /** * Executor-only installation gate. Register its fixed map before starting the @@ -37,16 +47,17 @@ export function createExecutorRuntimeInstallation(options: { const binding = parseExecutorInstallation(getExecutorBindingSchema(), options.binding); const artifact = parseExecutorInstallation(getExecutorArtifactManifestSchema(), options.artifact); const lifetime = new AbortController(); - const settled = Promise.withResolvers(); - void settled.promise.catch(() => {}); + const settled = createPrivateDeferred(); + void chainPrivatePromise(settled.promise, () => {}, () => {}); + const install = options.install; let phase: "empty" | "installing" | "installed" | "closed" = "empty"; let setup: Promise | undefined; let dispatch: ReadonlyMap | undefined; let closing: Promise | undefined; - const tasks = new Set>(); + const tasks = createPrivateSet>(); function retainOperation() { - const task = Promise.withResolvers(); + const task = createPrivateDeferred(); tasks.add(task.promise); return () => { tasks.delete(task.promise); @@ -70,34 +81,44 @@ export function createExecutorRuntimeInstallation(options: { dispatch = undefined; // Memoize before abort listeners can reenter. Retain even non-cooperative // setup and runtime work until actual settlement, not cancellation notice. - closing = Promise.resolve().then(async () => { + closing = chainPrivatePromise(resolvePrivatePromise(), async () => { let loaded: InstalledExecutorRuntime | undefined; try { loaded = await setup; } catch { /* Failed setup owns its partial resources. */ } if (loaded) { - const results = await Promise.allSettled([ - Promise.resolve().then(() => loaded.close()), - loaded.settled, - ]); - await Promise.allSettled([...tasks]); - if (results.some((result) => result.status === "rejected")) { + let failed = false; + const cleanup = chainPrivatePromise(resolvePrivatePromise(), () => loaded!.close()); + const cleanupObserved = chainPrivatePromise(cleanup, () => {}, () => { + failed = true; + }); + const retirementObserved = chainPrivatePromise(loaded.settled, () => {}, () => { + failed = true; + }); + await cleanupObserved; + await retirementObserved; + for (const task of tasks) { + try { + await observePrivatePromise(task); + } catch { /* Operation failure is reported to its caller. */ } + } + if (failed) { throw new Error("Executor installation cleanup failed"); } } }); lifetime.abort(new Error("Executor installation closed")); options.signal?.removeEventListener("abort", abort); - void closing.then(settled.resolve, settled.reject); + void chainPrivatePromise(closing, settled.resolve, settled.reject); return closing; } function abort() { - void close().catch(() => {}); + void chainPrivatePromise(close(), () => {}, () => {}); } options.signal?.addEventListener("abort", abort, { once: true }); if (options.signal?.aborted) abort(); - const operations = new Map(); + const operations = createPrivateMap(); operations.set("runtime.install", { mode: "unary", async handle(value, context) { @@ -111,29 +132,34 @@ export function createExecutorRuntimeInstallation(options: { ) throw new Error("Executor installation not granted"); phase = "installing"; context.signal.addEventListener("abort", abort, { once: true }); - setup = Promise.resolve().then(() => { - assertActive(context); - return options.install(input, lifetime.signal); - }); + setup = chainPrivatePromise( + resolvePrivatePromise(), + (): Promise => { + assertActive(context); + return apply(install, options, [input, lifetime.signal]); + }, + ); try { const loaded = await setup; assertActive(context); - const registered = new Map(loaded.operations); - for (const [name, mode] of Object.entries(operationModes)) { + const registered = copyPrivateMap(loaded.operations); + for (let index = 0; index < operationEntries.length; index++) { + const name = operationEntries[index]![0], mode = operationEntries[index]![1]; if (registered.get(name)?.mode !== mode) throw new Error("Incomplete executor runtime"); } dispatch = registered; phase = "installed"; return { installed: true }; } catch { - void close().catch(() => {}); + void chainPrivatePromise(close(), () => {}, () => {}); throw new Error("Executor installation failed"); } finally { context.signal.removeEventListener("abort", abort); } }, }); - for (const [name, mode] of Object.entries(operationModes)) { + for (let index = 0; index < operationEntries.length; index++) { + const name = operationEntries[index]![0], mode = operationEntries[index]![1]; if (mode === "unary") { operations.set(name, { mode, diff --git a/src/agent/hosted/executor-tool-bridge.ts b/src/agent/hosted/executor-tool-bridge.ts index 658ae3b257..6318c0be2e 100644 --- a/src/agent/hosted/executor-tool-bridge.ts +++ b/src/agent/hosted/executor-tool-bridge.ts @@ -1,4 +1,13 @@ import type { JsonValue } from "#veryfront/schemas/index.ts"; +import { copyPrivateSet, createPrivateSet } from "#veryfront/security/private-set.ts"; +import { copyPrivateMap, createPrivateMap } from "#veryfront/security/private-map.ts"; +import { pushPrivateArray } from "#veryfront/security/private-array.ts"; +import { + chainPrivatePromise, + createPrivateDeferred, + observePrivatePromise, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; import type { RemoteToolSource, ToolExecutionContext } from "#veryfront/tool/types.ts"; import type { ExecutorOperation, @@ -26,6 +35,10 @@ import { parseExecutorToolData, } from "#veryfront/agent/hosted/executor-tool-schema.ts"; +const apply = Reflect.apply; +const isArray = Array.isArray; +const hasOwn = Object.hasOwn; + /** Already-scoped capabilities. The source owns exact project, run, and skill policy. */ export interface ExecutorToolCapability { readonly source: RemoteToolSource; @@ -55,11 +68,13 @@ export function createExecutorToolBroker(options: { const maxConcurrent = executorToolLimit(options.maxConcurrent, 32); const binding = parseExecutorToolData(getExecutorBindingSchema(), options.scope.binding); const lifetime = options.scope.signal; - const assertActive = options.scope.assertActive.bind(options.scope); - if (!(lifetime instanceof AbortSignal) || options.sources.size > limits.maxSources) { + const scopeAssertion = options.scope.assertActive; + const assertActive = () => apply(scopeAssertion, options.scope, []); + const suppliedSources = copyPrivateMap(options.sources, limits.maxSources); + if (!(lifetime instanceof AbortSignal) || suppliedSources.size > limits.maxSources) { throw new TypeError("Invalid executor tool authority"); } - const sources = new Map limits.maxSources || sources.has(id) || !capability.context || capability.source.id !== id || typeof capability.source.listTools !== "function" || typeof capability.source.executeTool !== "function" || - capability.allowedToolNames.size > limits.maxToolsPerSource + allowed.size > limits.maxToolsPerSource ) { throw new TypeError("Invalid executor tool capability"); } parseExecutorToolData(getExecutorToolIdSchema(), id); - const allowed = new Set(); - for (const name of capability.allowedToolNames) { - if (++allowedTools > limits.maxTotalTools || allowed.size >= limits.maxToolsPerSource) { + for (const name of allowed) { + if (++allowedTools > limits.maxTotalTools) { throw new TypeError("Executor tool allowlist exceeds its limit"); } - allowed.add(parseExecutorToolData(getExecutorToolIdSchema(), name)); + parseExecutorToolData(getExecutorToolIdSchema(), name); } sources.set(id, { source: capability.source, @@ -166,8 +181,8 @@ export function createExecutorToolBroker(options: { const result = yield* callWithProgress({ invoke: (context) => call - ? capability.execute.call(capability.source, call.toolName, call.args, context) - : capability.list.call(capability.source, context), + ? apply(capability.execute, capability.source, [call.toolName, call.args, context]) + : apply(capability.list, capability.source, [context]), context: capability.context, publisher: capability.publisher, publisherReceiver: capability.publisherReceiver, @@ -188,25 +203,27 @@ export function createExecutorToolBroker(options: { }); } else { if ( - !Array.isArray(result) || result.length > limits.maxToolsPerSource || + !isArray(result) || result.length > limits.maxToolsPerSource || metadataTools + result.length > limits.maxTotalTools ) { throw createExecutorModelFailure("RESOURCE_LIMIT_EXCEEDED"); } metadataTools += result.length; - const names = new Set(); + const names = createPrivateSet(); const frames: JsonValue[] = []; // Snapshot the bounded catalog before the first yield. A stateful // source may reuse or mutate its array while the consumer is paused. - for (const raw of result) { + for (let index = 0; index < result.length; index++) { + const raw = result[index]; const definition = executorToolDefinition(raw, limits); if (names.has(definition.name)) throw new TypeError("Duplicate executor tool definition"); names.add(definition.name); const frame = executorToolJson({ type: "tool", definition }); accountMetadata(frame); - if (capability.allowed.has(definition.name)) frames.push(frame); + if (capability.allowed.has(definition.name)) pushPrivateArray(frames, frame); } - for (const frame of frames) { + for (let index = 0; index < frames.length; index++) { + const frame = frames[index]!; assertCall(); yield frame; } @@ -222,17 +239,20 @@ export function createExecutorToolBroker(options: { } } - return new Map([ - ["tool.sources", { - mode: "stream", - handle: (value, context) => handle("sources", value, context), - }], - ["tool.list", { mode: "stream", handle: (value, context) => handle("list", value, context) }], - ["tool.execute", { - mode: "stream", - handle: (value, context) => handle("execute", value, context), - }], - ]); + const operations = createPrivateMap(); + operations.set("tool.sources", { + mode: "stream", + handle: (value, context) => handle("sources", value, context), + }); + operations.set("tool.list", { + mode: "stream", + handle: (value, context) => handle("list", value, context), + }); + operations.set("tool.execute", { + mode: "stream", + handle: (value, context) => handle("execute", value, context), + }); + return operations; } /** Keep original execution and publisher promises inside the channel handler lifetime. */ @@ -248,15 +268,16 @@ async function* callWithProgress(options: { }): AsyncGenerator { const abort = new AbortController(); const signal = AbortSignal.any([options.signal, abort.signal]); - type Acknowledgement = ReturnType>; + type Acknowledgement = ReturnType>; const queue: { frame: JsonValue; bytes: number; work: Promise; acknowledgement: Acknowledgement; }[] = []; - const pending = new Set>(); - const acknowledgements = new Set(); + const pending = createPrivateSet>(); + const acknowledgements = createPrivateSet(); + let queueHead = 0; let wake: (() => void) | undefined; let accepting = true; let settled = false; @@ -315,24 +336,29 @@ async function* callWithProgress(options: { } retainedCount++; retainedBytes += bytes; - const publication = Promise.withResolvers(); + const publication = createPrivateDeferred(); const work = publication.promise; - const acknowledgement = Promise.withResolvers(); + const acknowledgement = createPrivateDeferred(); acknowledgements.add(acknowledgement); // Ignored publications still have an observed, bounded acknowledgement. - void acknowledgement.promise.catch(() => {}); + void chainPrivatePromise(acknowledgement.promise, () => {}, () => {}); pending.add(work); - void work.then(() => pending.delete(work), (error) => { + void chainPrivatePromise(work, () => { + pending.delete(work); + }, (error) => { pending.delete(work); fail(error); }); - queue.push({ frame, bytes, work, acknowledgement }); + pushPrivateArray(queue, { frame, bytes, work, acknowledgement }); try { // Start the original callback synchronously, after reserving space. // Keep its promise joined even if the next publication overflows. // A separate snapshot prevents it from changing queued wire data. - const original = options.publisher?.call(options.publisherReceiver, snapshot); - void Promise.resolve(original).then(() => { + const original = options.publisher === undefined + ? undefined + : apply(options.publisher, options.publisherReceiver, [snapshot]); + const observed = chainPrivatePromise(resolvePrivatePromise(), () => original); + void chainPrivatePromise(observed, () => { try { check(); publication.resolve(); @@ -352,22 +378,29 @@ async function* callWithProgress(options: { } }, }; - const execution = Promise.resolve().then(() => { + const started = chainPrivatePromise(resolvePrivatePromise(), () => { check(); return options.invoke(context); - }).then((value) => { + }); + const completed = chainPrivatePromise(started, (value) => { result = value; - }, fail).finally(() => { + }, fail); + const finish = () => { accepting = false; settled = true; wake?.(); + }; + const execution = chainPrivatePromise(completed, finish, (error) => { + finish(); + throw error; }); try { while (true) { if (failed) throw failure; check(); - const next = queue.shift(); + const next = hasOwn(queue, queueHead) ? queue[queueHead] : undefined; if (next) { + delete queue[queueHead++]; await next.work; if (failed) throw failure; check(); @@ -381,9 +414,9 @@ async function* callWithProgress(options: { next.acknowledgement.resolve(); } else if (settled) break; else { - await new Promise((resolve) => { - wake = resolve; - }); + const notification = createPrivateDeferred(); + wake = notification.resolve; + await notification.promise; } } return result; @@ -399,7 +432,11 @@ async function* callWithProgress(options: { rejectAcknowledgements(); signal.removeEventListener("abort", notifyAbort); await execution; - await Promise.allSettled(pending); + for (const work of pending) { + try { + await observePrivatePromise(work); + } catch { /* The first failure is retained above. */ } + } queue.length = 0; } } diff --git a/src/agent/hosted/executor-tool-schema.test.ts b/src/agent/hosted/executor-tool-schema.test.ts index fe7aadf86c..ac33d917d5 100644 --- a/src/agent/hosted/executor-tool-schema.test.ts +++ b/src/agent/hosted/executor-tool-schema.test.ts @@ -1,7 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { JsonValue } from "#veryfront/schemas/index.ts"; import { + executorToolBytes, executorToolDefinition, executorToolJson, executorToolLimits, @@ -12,6 +14,14 @@ import { } from "./executor-tool-schema.ts"; describe("executor tool schema", () => { + it("preserves the existing node and depth limits while measuring array-heavy JSON", () => { + const value = { items: Array.from({ length: 50_000 }, () => []) }; + assertEquals(executorToolBytes(executorToolJson(value)), 150_011); + let nested: JsonValue = []; + for (let depth = 0; depth < 128; depth++) nested = [nested]; + assertEquals(executorToolBytes(executorToolJson(nested)), 258); + }); + it("requires data-only JSON without coercing unsupported values", () => { const circular: Record = {}; circular.self = circular; diff --git a/src/agent/hosted/executor-tool-schema.ts b/src/agent/hosted/executor-tool-schema.ts index eb6fe74737..7e28304488 100644 --- a/src/agent/hosted/executor-tool-schema.ts +++ b/src/agent/hosted/executor-tool-schema.ts @@ -1,7 +1,10 @@ import type { InferSchema, Schema, SchemaValidator } from "#veryfront/extensions/schema/index.ts"; import { defineSchema, getJsonValueSchema, type JsonValue } from "#veryfront/schemas/index.ts"; -import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import { boundedJsonByteLength, snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; import { getEnumerableOwnStringDataEntries } from "#veryfront/tool/data-properties.ts"; +import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { findLastPrivateArrayIndex, somePrivateArray } from "#veryfront/security/private-array.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { isToolAnnotations } from "#veryfront/tool/mcp-metadata.ts"; import type { ToolDefinition, ToolExecutionDataEvent } from "#veryfront/tool/types.ts"; import { CURATED_PROVIDER_FAILURE_CODES } from "#veryfront/chat/provider-error-registry.ts"; @@ -13,6 +16,18 @@ import { } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { executorModelFailure } from "#veryfront/agent/hosted/executor-model-errors.ts"; +const objectKeys = Object.keys; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const isArray = Array.isArray; +const freeze = Object.freeze; +const definitionKeys = createPrivateSet([ + "name", + "description", + "parameters", + "title", + "annotations", +]); + // Reserve the channel envelope, including escaped binding strings and the prefix. export const EXECUTOR_TOOL_MAX_PAYLOAD_BYTES = EXECUTOR_MAX_FRAME_BYTES - 2048; export const EXECUTOR_TOOL_LIMITS = Object.freeze({ @@ -43,10 +58,12 @@ export function executorToolLimits( overrides: Partial = {}, ): ExecutorToolLimits { const limits: ExecutorToolLimits = { ...EXECUTOR_TOOL_LIMITS }; - for (const key of Object.keys(limits) as (keyof ExecutorToolLimits)[]) { + const keys = objectKeys(limits) as (keyof ExecutorToolLimits)[]; + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; limits[key] = executorToolLimit(overrides[key] ?? limits[key], limits[key]); } - return Object.freeze(limits); + return freeze(limits); } export const getExecutorToolIdSchema = defineSchema((v) => v.string().min(1).max(256)); @@ -89,10 +106,10 @@ export const getExecutorToolFrameSchema = defineSchema((v) => ); export type ExecutorToolFrame = InferSchema>; -const JSON_BYTE_ENCODER = new TextEncoder(); - export function executorToolBytes(value: JsonValue): number { - return JSON_BYTE_ENCODER.encode(JSON.stringify(value)).byteLength; + const size = boundedJsonByteLength(value); + if (size === undefined) throw new TypeError("Executor tool data exceeds its JSON limits"); + return size; } /** Snapshot before validation or serialization. Never invoke toJSON or coerce non-data values. */ @@ -115,28 +132,26 @@ export function parseExecutorToolData(schema: Schema, value: unknown): T { } export function executorToolDefinition(value: unknown, limits: ExecutorToolLimits): ToolDefinition { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!value || typeof value !== "object" || isArray(value)) { throw new TypeError("Invalid executor tool definition"); } // ToolDefinition permits explicit undefined for optional metadata. Omit only // these top-level values; schema properties themselves remain strict JSON. const entries = getEnumerableOwnStringDataEntries(value, "Executor tool definition"); - const data = executorToolJson( - Object.fromEntries( - entries.filter(([key, entry]) => - entry !== undefined || (key !== "title" && key !== "annotations") - ), - ), - limits.maxDescriptorBytes, - ); + const record = {}; + for (let index = 0; index < entries.length; index++) { + const key = entries[index]![0], entry = entries[index]![1]; + if (entry !== undefined || (key !== "title" && key !== "annotations")) { + defineOwnDataProperty(record, key, entry, { enumerable: true }); + } + } + const data = executorToolJson(record, limits.maxDescriptorBytes); if ( - !data || typeof data !== "object" || Array.isArray(data) || - Object.keys(data).some((key) => - !["name", "description", "parameters", "title", "annotations"].includes(key) - ) || + !data || typeof data !== "object" || isArray(data) || + somePrivateArray(objectKeys(data), (key) => !definitionKeys.has(key)) || typeof data.name !== "string" || !getExecutorToolIdSchema().safeParse(data.name).success || typeof data.description !== "string" || - !data.parameters || typeof data.parameters !== "object" || Array.isArray(data.parameters) || + !data.parameters || typeof data.parameters !== "object" || isArray(data.parameters) || (data.title !== undefined && typeof data.title !== "string") || (data.annotations !== undefined && !isToolAnnotations(data.annotations)) ) throw new TypeError("Invalid executor tool definition"); @@ -155,7 +170,7 @@ export function executorToolProgress( ): ToolExecutionDataEvent { const event = executorToolJson(value, limits.maxProgressEventBytes); if ( - !event || typeof event !== "object" || Array.isArray(event) || + !event || typeof event !== "object" || isArray(event) || typeof event.type !== "string" || !event.type.length || event.type.length > 256 ) { throw new TypeError("Invalid executor tool progress"); @@ -168,12 +183,14 @@ export function executorToolFailure(error: unknown): ExecutorToolFrame { // an unknown error authority to classify a reply. Agent codes need an // explicit code or registered error; model codes use the curated helper. const explicit = error !== null && typeof error === "object" - ? Object.getOwnPropertyDescriptor(error, "code")?.value + ? getOwnPropertyDescriptor(error, "code")?.value : undefined; - const classified = snapshotVeryfrontError(error) || failureCodes.some((code) => code === explicit) - ? executorAgentFailureCode(error, "EXECUTOR_AGENT_STREAM_FAILED") - : executorModelFailure(error)?.code; - const code = failureCodes.find((code) => code === classified); + const classified = + snapshotVeryfrontError(error) || somePrivateArray(failureCodes, (code) => code === explicit) + ? executorAgentFailureCode(error, "EXECUTOR_AGENT_STREAM_FAILED") + : executorModelFailure(error)?.code; + const index = findLastPrivateArrayIndex(failureCodes, (code) => code === classified); + const code = index < 0 ? undefined : failureCodes[index]; return { type: "failure", ...(code ? { code } : {}) }; } diff --git a/src/schemas/json-value.ts b/src/schemas/json-value.ts index 3da260df6e..568ba485c0 100644 --- a/src/schemas/json-value.ts +++ b/src/schemas/json-value.ts @@ -86,7 +86,7 @@ function invalidSnapshotPath(path: SnapshotPathNode | undefined): InvalidSnapsho function invalidJsonSnapshot( path: SnapshotPathNode | undefined, -): BoundedJsonSnapshot { +): Extract { let segmentCount = 0; for (let current = path; current !== undefined; current = current.parent) { segmentCount += 1; @@ -134,6 +134,19 @@ function encodedByteLength(value: string): number { * code. */ export function snapshotBoundedJsonValue(value: unknown): BoundedJsonSnapshot { + const result = snapshotBoundedJsonWithSize(value); + return result.success ? { success: true, value: result.value } : result; +} + +/** Exact serialized UTF-8 size under the snapshot's existing limits, without serialization hooks. */ +export function boundedJsonByteLength(value: unknown): number | undefined { + const result = snapshotBoundedJsonWithSize(value); + return result.success ? result.serializedBytes : undefined; +} + +function snapshotBoundedJsonWithSize(value: unknown): + | { success: true; value: BoundedJsonValue; serializedBytes: number } + | { success: false; path: readonly BoundedJsonPathSegment[] } { let activePath: SnapshotPathNode | undefined; try { const activeAncestors = new NativeSet(); @@ -241,7 +254,7 @@ export function snapshotBoundedJsonValue(value: unknown): BoundedJsonSnapshot { } return rootAssigned - ? { success: true, value: canonicalRoot as BoundedJsonValue } + ? { success: true, value: canonicalRoot as BoundedJsonValue, serializedBytes } : invalidJsonSnapshot(undefined); } catch { // Proxy traps and reflective operations can throw. Such values are not diff --git a/src/security/private-collection-copy.test.ts b/src/security/private-collection-copy.test.ts new file mode 100644 index 0000000000..61dc665459 --- /dev/null +++ b/src/security/private-collection-copy.test.ts @@ -0,0 +1,33 @@ +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { copyPrivateSet } from "./private-set.ts"; +import { copyPrivateMap } from "./private-map.ts"; + +Deno.test("private collection copy baseline ignores instance traversal hooks", () => { + const members = new Set(["granted"]); + const entries = new Map([["granted", 1]]); + const unexpected = () => { + throw new Error("Caller traversal was used"); + }; + Object.defineProperties(members, { + [Symbol.iterator]: { value: unexpected }, + forEach: { value: unexpected }, + has: { value: unexpected }, + size: { get: unexpected }, + }); + Object.defineProperties(entries, { + [Symbol.iterator]: { value: unexpected }, + forEach: { value: unexpected }, + get: { value: unexpected }, + size: { get: unexpected }, + }); + const memberCopy = copyPrivateSet(members); + const entryCopy = copyPrivateMap(entries); + members.add("later"); + entries.set("later", 2); + assertEquals([...memberCopy], ["granted"]); + assertEquals(memberCopy.has("denied"), false); + assertEquals([...entryCopy], [["granted", 1]]); + assertEquals(entryCopy.get("denied"), undefined); + assertThrows(() => copyPrivateSet(members, 1), TypeError, "limit"); + assertThrows(() => copyPrivateMap(entries, 1), TypeError, "limit"); +}); diff --git a/src/security/private-map.ts b/src/security/private-map.ts index a5b1400b79..972ca177a5 100644 --- a/src/security/private-map.ts +++ b/src/security/private-map.ts @@ -17,6 +17,8 @@ const mapEntries = Map.prototype.entries; const iteratorSymbol: typeof Symbol.iterator = Symbol.iterator; const iteratorNext = Object.getPrototypeOf(new MapConstructor().values()).next; const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size")!.get!; +const isSafeInteger = Number.isSafeInteger; +const maxSafeInteger = Number.MAX_SAFE_INTEGER; function protectEntry(entry: [K, V]): [K, V] { defineOwnDataProperty(entry, iteratorSymbol, () => { @@ -86,3 +88,16 @@ export function createPrivateMap(): Map { defineProperty(map, "size", sizeDescriptor); return freeze(map); } + +/** Copy native entries without consulting a caller-replaceable iterator or method. */ +export function copyPrivateMap( + source: ReadonlyMap, + maximum = maxSafeInteger, +): Map { + if (!isSafeInteger(maximum) || maximum < 0 || apply(mapSize, source, []) > maximum) { + throw new TypeError("Private map limit exceeded"); + } + const result = createPrivateMap(); + apply(mapForEach, source, [(value: V, key: K) => result.set(key, value)]); + return result; +} diff --git a/src/security/private-set.ts b/src/security/private-set.ts index 6e4f5db1ee..cf46dbd2ff 100644 --- a/src/security/private-set.ts +++ b/src/security/private-set.ts @@ -11,9 +11,12 @@ const setHas = Set.prototype.has; const setDelete = Set.prototype.delete; const setClear = Set.prototype.clear; const setValues = Set.prototype.values; +const setForEach = Set.prototype.forEach; const iteratorSymbol: typeof Symbol.iterator = Symbol.iterator; const iteratorNext = Object.getPrototypeOf(new SetConstructor().values()).next; const setSize = Object.getOwnPropertyDescriptor(Set.prototype, "size")!.get!; +const isSafeInteger = Number.isSafeInteger; +const maxSafeInteger = Number.MAX_SAFE_INTEGER; /** Internal selector set whose used operations do not consult mutable prototypes. */ export function createPrivateSet(values?: Iterable): Set { @@ -53,3 +56,13 @@ export function createPrivateSet(values?: Iterable): Set { } return freeze(set); } + +/** Copy native membership without consulting a caller-replaceable iterator or method. */ +export function copyPrivateSet(source: ReadonlySet, maximum = maxSafeInteger): Set { + if (!isSafeInteger(maximum) || maximum < 0 || apply(setSize, source, []) > maximum) { + throw new TypeError("Private set limit exceeded"); + } + const result = createPrivateSet(); + apply(setForEach, source, [(value: T) => result.add(value)]); + return result; +} diff --git a/tests/integration/agent/fixtures/trusted-project/probe.ts b/tests/integration/agent/fixtures/trusted-project/probe.ts index 273bfe97f1..ef2ad6090b 100644 --- a/tests/integration/agent/fixtures/trusted-project/probe.ts +++ b/tests/integration/agent/fixtures/trusted-project/probe.ts @@ -1,3 +1,5 @@ +import process from "node:process"; + type FixtureGlobals = typeof globalThis & { __vfNativeObservations?: string[]; __vfNativeCalls?: number; @@ -20,6 +22,9 @@ function observe(value: unknown) { } export function installHooks() { + if (process.env.VF_NATIVE_PATCH_MEMBERSHIP === "1") { + Set.prototype.has = () => true; + } const trim = String.prototype.trim; String.prototype.trim = function () { observe(this); @@ -49,6 +54,10 @@ export function installHooks() { } as typeof then; } +export function recordDeniedToolExecution() { + seen.push("ungranted tool executed"); +} + export function observations(): string[] { return [...seen]; } diff --git a/tests/integration/agent/fixtures/trusted-project/tools/denied.ts b/tests/integration/agent/fixtures/trusted-project/tools/denied.ts new file mode 100644 index 0000000000..c737d86d1d --- /dev/null +++ b/tests/integration/agent/fixtures/trusted-project/tools/denied.ts @@ -0,0 +1,13 @@ +import { tool } from "veryfront/tool"; +import { defineSchema } from "veryfront/schemas"; +import { recordDeniedToolExecution } from "../probe.ts"; + +export default tool({ + id: "denied", + description: "Synthetic tool outside the invocation allowlist", + inputSchema: defineSchema((v) => v.object({}))(), + execute: () => { + recordDeniedToolExecution(); + return { unexpected: true }; + }, +}); diff --git a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts index 3fd2b9c0d2..e91dcbea1c 100644 --- a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts +++ b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts @@ -32,6 +32,7 @@ export default tool({ }), observations: observations(), hasParentSecret: Object.hasOwn(process.env, "VF_NATIVE_PARENT_SECRET"), + patchedMembership: new Set().has("denied"), }); }, }); diff --git a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts index b660e08e7a..86d2292a1d 100644 --- a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts +++ b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts @@ -12,7 +12,7 @@ import { scriptedModel } from "#veryfront/agent/runtime/model-runtime.test-helpe import type { ProviderReplayCheckpoint } from "#veryfront/agent/runtime/provider-replay.ts"; export async function runNativeTrustedScenario( - mode: "complete" | "cancel" | "crash" | "startup-failure" | "denied", + mode: "complete" | "cancel" | "crash" | "startup-failure" | "denied" | "collections", ) { const root = new URL("../../../../", import.meta.url); const startedAt = performance.now(); @@ -32,7 +32,12 @@ export async function runNativeTrustedScenario( fileURLToPath(new URL("./trusted-project-executor.ts", import.meta.url)), ], { cwd: fileURLToPath(root), - env: { PATH: process.env.PATH, NODE_ENV: "test", DENO_TESTING: "1" }, + env: { + PATH: process.env.PATH, + NODE_ENV: "test", + DENO_TESTING: "1", + ...(mode === "collections" ? { VF_NATIVE_PATCH_MEMBERSHIP: "1" } : {}), + }, stdio: ["pipe", "pipe", "pipe"], }); child.stdin.end(JSON.stringify({ binding, key: [...key], context, mode }) + "\n"); @@ -86,6 +91,22 @@ export async function runNativeTrustedScenario( allowedToolNames: new Set(["inspect"]), assertActive() {}, }); + if (mode === "collections") { + const frames = await Array.fromAsync(channel.stream("tool.list", { sourceId: "project" })); + assertEquals( + frames.filter((frame) => + frame && typeof frame === "object" && !Array.isArray(frame) && frame.type === "tool" + ).length, + 1, + ); + const denied = await Array.fromAsync(channel.stream("tool.execute", { + sourceId: "project", + toolName: "denied", + toolCallId: "denied", + args: {}, + })); + assertEquals(denied, [{ type: "failure" }]); + } if (mode === "denied") { await assertRejects(() => source.executeTool("ungranted", {}, { toolCallId: "denied" })); await assertRejects(() => @@ -228,7 +249,7 @@ export async function runNativeTrustedScenario( }], }, opContext), ); - if (mode !== "complete") { + if (mode === "cancel" || mode === "crash") { await assertRejects(() => reading); await owner.close(); assertEquals(runtimeCleanups, 1); @@ -263,6 +284,7 @@ export async function runNativeTrustedScenario( ], observations: [], hasParentSecret: false, + patchedMembership: mode === "collections", }]); assertEquals(model.callCount, 2); assertEquals(checkpoints.length, 2); diff --git a/tests/integration/agent/trusted-runtime-preparation.test.ts b/tests/integration/agent/trusted-runtime-preparation.test.ts index 153fef0235..6241fe0757 100644 --- a/tests/integration/agent/trusted-runtime-preparation.test.ts +++ b/tests/integration/agent/trusted-runtime-preparation.test.ts @@ -41,7 +41,16 @@ if ("Deno" in globalThis || "Bun" in globalThis) { } else { const { runNativeTrustedScenario } = await import("./fixtures/trusted-runtime-scenario.ts"); describe("trusted hosted native execution", () => { - for (const mode of ["complete", "cancel", "crash", "startup-failure", "denied"] as const) { + for ( + const mode of [ + "complete", + "cancel", + "crash", + "startup-failure", + "denied", + "collections", + ] as const + ) { it( `preserves the trust boundary and original-work ownership on ${mode}`, { timeout: 30_000 }, From 9ef5d488efe94072a2026f38f943e49341d874c0 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 17:38:19 +0200 Subject: [PATCH 08/19] fix(agent): capture project installation authority before discovery --- docs/api-reference/veryfront/agent.md | 8 +- docs/guides/agent-service-runtime.md | 55 ++++++++++++-- .../hosted/executor-project-runtime.test.ts | 59 ++++++++++++++- src/agent/hosted/executor-project-runtime.ts | 75 +++++++++++++------ .../hosted/executor-runtime-entrypoint.ts | 2 + 5 files changed, 165 insertions(+), 34 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index fe1ab2ed27..bbb1247047 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -1932,10 +1932,10 @@ import { #### Functions -| Name | Description | Source | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `initializeExecutorRuntimeContracts` | Install the fixed first-party contracts required before executor project imports. Concurrent and repeated startup preserves any already-registered trusted generation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-runtime-contracts.ts) | -| `startExecutorRuntimeEntrypoint` | Dedicated executor entrypoint. The reviewed image launcher registers its first-party SchemaValidator, Bundler, ModuleLexer and SkillDocumentParserProvider before calling this function. The fixed image manifest is outside the project tree and is never selected by channel input. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-runtime-entrypoint.ts) | +| Name | Description | Source | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `initializeExecutorRuntimeContracts` | Install the fixed first-party contracts required before executor project imports. Concurrent and repeated startup preserves any already-registered trusted generation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-runtime-contracts.ts) | +| `startExecutorRuntimeEntrypoint` | Dedicated executor entrypoint. The reviewed image launcher registers its first-party SchemaValidator, Bundler, ModuleLexer and SkillDocumentParserProvider before calling this function. The fixed image manifest is outside the project tree and is never selected by channel input. The default runtime profile installs agent grants and capabilities. The project-tools profile installs only fixed-context project tool operations; it cannot prepare or stream agents. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/executor-runtime-entrypoint.ts) | ### `veryfront/agent/identity` diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index a47a4c65fa..a88867e683 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -445,11 +445,56 @@ parser, then supplies the Operator allocation environment and image manifest. Missing runtime contracts fail startup. The executor accepts one authenticated `runtime.install` message bound to its -allocation, invocation, generation, owner, and immutable source. Discovery and -runtime preparation remain unavailable until installation succeeds. The -installation carries runtime grants and capability IDs. Initial checkpoint -state uses a separate bounded stream so durable replay state can exceed the -installation message limit. +allocation, invocation, generation, owner, and immutable source. Discovery remains +unavailable until installation succeeds. The trusted image launcher selects the +profile at startup; channel messages cannot change it. + +### Installation profiles + +| Startup `mode` | Installation data | Operations after installation | +| ------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `runtime` (default) | Runtime grant, capability IDs, optional host-tool aliases | `discovery.describe`, `agent.describe`, `runtime.prepare`, `agent.stream` | +| `project-tools` | Fixed agent/project/run context, canonical tool allowlist, call and concurrency limits | `discovery.describe`, `agent.describe`, `project.tool-aliases`, `tool.sources`, `tool.list`, `tool.execute` | + +The full-runtime profile uses a separate bounded stream for initial checkpoint +state, so durable replay state can exceed the installation message limit. + +The project-tools profile is selected by +`startExecutorRuntimeEntrypoint({ mode: "project-tools" })`. Its installation has +the following shape; the broker supplies the actual allocation and source identities: + +```json +{ + "version": 1, + "mode": "project-tools", + "binding": { + "allocationId": "allocation-example", + "generation": 1, + "invocationId": "invocation-example" + }, + "owner": { "scopeKind": "project", "projectId": "project-example" }, + "source": { "type": "release", "releaseId": "release-example" }, + "root": "project", + "context": { + "agentId": "assistant", + "projectId": "project-example", + "runId": "run-example" + }, + "allowedToolNames": ["inspect"], + "maxCalls": 32, + "maxConcurrent": 2 +} +``` + +`allowedToolNames` contains unique canonical names, with at most 1024 entries. +`maxCalls` is an integer from 1 to 4096; `maxConcurrent` is an integer from 1 to 32. +The fixed context binds tool calls to the admitted canonical run. Correlation IDs, +cancellation and progress use the authenticated channel. This payload accepts no +runtime grants, private credentials or host capability IDs, and rejects unknown +fields. This profile exposes neither runtime preparation nor agent streaming; +the trusted broker owns the agent loop and privileged operations. + +### Broker composition The broker owns HTTP authentication, credentials, model and tool authorization, and durable persistence. Executor facades call these capabilities through the diff --git a/src/agent/hosted/executor-project-runtime.test.ts b/src/agent/hosted/executor-project-runtime.test.ts index f2a1d2800d..6a2a28348f 100644 --- a/src/agent/hosted/executor-project-runtime.test.ts +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -27,7 +27,7 @@ const install = () => maxCalls: 32, maxConcurrent: 2, }); -function fixture(wait?: Promise) { +function fixture(wait?: Promise, onLoad?: () => void) { let cleaned = 0; let loads = 0; let calls = 0; @@ -77,6 +77,7 @@ function fixture(wait?: Promise) { backend: { load: () => { loads++; + onLoad?.(); return Promise.resolve(runtime); }, cleanup: () => { @@ -102,6 +103,62 @@ function fixture(wait?: Promise) { } describe("installed project tool runtime", () => { + it("keeps original cleanup when discovery replaces the public close callback", async () => { + const input = install(); + input.context.agentId = "missing"; + const f = fixture(undefined, () => { + f.discovery.close = () => Promise.resolve(); + }); + await assertRejects(() => + createExecutorProjectToolRuntime({ + input, + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }) + ); + assertEquals(f.loads, 1); + assertEquals(f.cleaned, 1); + }); + + it("captures admitted authority before project loading can mutate the caller input", async () => { + const input = install(); + const f = fixture(undefined, () => { + input.context.agentId = "foreign"; + input.context.runId = "foreign-run"; + input.allowedToolNames.length = 0; + input.maxCalls = 1; + input.binding.generation = 2; + }); + const owner = await createExecutorProjectToolRuntime({ + input, + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + try { + const operation = owner.operations.get("tool.execute"); + assert(operation?.mode === "stream"); + const frames = await Array.fromAsync(operation.handle({ + sourceId: "project", + toolName: "inspect", + toolCallId: "call", + args: { query: "approved" }, + }, { binding, signal: f.lifetime.signal, deadline: Date.now() + 10_000 })); + assertEquals(frames, [{ + type: "result", + result: { + query: "approved", + agentId: "coder", + projectId: "synthetic-project", + runId: "synthetic-run", + }, + }]); + } finally { + await owner.close(); + } + }); + it("loads the bound project and executes only an explicitly granted project tool", async () => { const f = fixture(); const owner = await createExecutorProjectToolRuntime({ diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts index 8910b7a14c..5b5312806e 100644 --- a/src/agent/hosted/executor-project-runtime.ts +++ b/src/agent/hosted/executor-project-runtime.ts @@ -1,4 +1,5 @@ -import { createPrivateMap } from "#veryfront/security/private-map.ts"; +import { copyPrivateMap, createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; import { chainPrivatePromise, createPrivateDeferred, @@ -10,11 +11,21 @@ import { getExecutorAgentDescribeResultSchema, parseDiscoveryData, } from "./executor-discovery-schema.ts"; -import { createExecutorProjectToolOperations } from "./executor-project-tools.ts"; -import type { ExecutorProjectToolInstall } from "./executor-runtime-install-schema.ts"; +import { + createExecutorProjectToolOperations, + type ExecutorProjectToolContext, +} from "./executor-project-tools.ts"; +import { + type ExecutorProjectToolInstall, + getExecutorProjectToolInstallSchema, + parseExecutorInstallation, +} from "./executor-runtime-install-schema.ts"; +import { executorToolLimits } from "./executor-tool-schema.ts"; import type { InstalledExecutorRuntime } from "./executor-runtime-install.ts"; import { verifyHostedRuntimeSourceBinding } from "./runtime-source-binding.ts"; +const apply = Reflect.apply; + /** Called only after the authenticated project-only installation has matched the fixed image. */ export async function createExecutorProjectToolRuntime(options: { input: ExecutorProjectToolInstall; @@ -22,49 +33,65 @@ export async function createExecutorProjectToolRuntime(options: { signal: AbortSignal; deadline: number; }): Promise { - const { input, discovery, deadline } = options; + const { discovery, deadline } = options; + const close = discovery.close; const signal = AbortSignal.any([options.signal, discovery.signal]); try { + // Capture all admitted authority before discovery evaluates project modules. + const input = parseExecutorInstallation(getExecutorProjectToolInstallSchema(), options.input); + const binding = input.binding; + const source = input.source; + const context: ExecutorProjectToolContext = { + agentId: input.context.agentId, + projectId: input.context.projectId, + execution: { kind: "canonical", runId: input.context.runId }, + }; + const allowedToolNames = createPrivateSet(input.allowedToolNames); + const maxCalls = input.maxCalls; + const maxConcurrent = input.maxConcurrent; + const limits = executorToolLimits(); + const discoveryOperations = copyPrivateMap(discovery.operations); + const getRuntime = discovery.getRuntime; + const retainRuntimeTask = discovery.retainRuntimeTask; + const settled = discovery.settled; signal.throwIfAborted(); - const describe = discovery.operations.get("agent.describe"); + const describe = discoveryOperations.get("agent.describe"); if (describe?.mode !== "unary") throw new Error("Project discovery unavailable"); const result = parseDiscoveryData( getExecutorAgentDescribeResultSchema(), await chainPrivatePromise(resolvePrivatePromise(), () => describe.handle( - { agentId: input.context.agentId }, - { binding: input.binding, signal, deadline }, + { agentId: context.agentId }, + { binding, signal, deadline }, )), true, ); signal.throwIfAborted(); if ( - !result.ok || result.value.definition.id !== input.context.agentId || - verifyHostedRuntimeSourceBinding(input.source, result.value.source) !== undefined + !result.ok || result.value.definition.id !== context.agentId || + verifyHostedRuntimeSourceBinding(source, result.value.source) !== undefined ) { throw new Error("Project discovery did not match installation"); } + const runtime: ReturnType = apply(getRuntime, discovery, []); const tools = createExecutorProjectToolOperations({ - scope: { binding: input.binding, signal, assertActive: () => signal.throwIfAborted() }, - context: { - agentId: input.context.agentId, - projectId: input.context.projectId, - execution: { kind: "canonical", runId: input.context.runId }, - }, - tools: discovery.getRuntime().tools, - allowedToolNames: new Set(input.allowedToolNames), - maxCalls: input.maxCalls, - maxConcurrent: input.maxConcurrent, + scope: { binding, signal, assertActive: () => signal.throwIfAborted() }, + context, + tools: runtime.tools, + allowedToolNames, + maxCalls, + maxConcurrent, + limits, }); const operations = createPrivateMap(); - for (const [name, operation] of discovery.operations) operations.set(name, operation); + for (const [name, operation] of discoveryOperations) operations.set(name, operation); for (const [name, operation] of tools) { if (operation.mode === "unary") operations.set(name, operation); else {operations.set(name, { mode: "stream", async *handle(value, context) { const retained = createPrivateDeferred(); - discovery.retainRuntimeTask(retained.promise); + apply(retainRuntimeTask, discovery, [retained.promise]); try { yield* operation.handle(value, context); } finally { @@ -75,11 +102,11 @@ export async function createExecutorProjectToolRuntime(options: { } return { operations, - close: () => discovery.close(), - settled: discovery.settled, + close: () => apply(close, discovery, []), + settled, }; } catch (error) { - await discovery.close(); + await apply(close, discovery, []); throw error; } } diff --git a/src/agent/hosted/executor-runtime-entrypoint.ts b/src/agent/hosted/executor-runtime-entrypoint.ts index 6a5b0a2d1e..83d5459ada 100644 --- a/src/agent/hosted/executor-runtime-entrypoint.ts +++ b/src/agent/hosted/executor-runtime-entrypoint.ts @@ -56,6 +56,8 @@ async function readFixedArtifact() { * first-party SchemaValidator, Bundler, ModuleLexer and SkillDocumentParserProvider before calling * this function. The fixed image * manifest is outside the project tree and is never selected by channel input. + * The default runtime profile installs agent grants and capabilities. The project-tools + * profile installs only fixed-context project tool operations; it cannot prepare or stream agents. */ export async function startExecutorRuntimeEntrypoint( options: Pick & { From ebe8c0343edc3afa57be34a24617d80e69d6d420 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 17:45:17 +0200 Subject: [PATCH 09/19] test(agent): target the denied tool in the collection attack probe --- tests/integration/agent/fixtures/trusted-project/probe.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/agent/fixtures/trusted-project/probe.ts b/tests/integration/agent/fixtures/trusted-project/probe.ts index ef2ad6090b..5dfc5a6e5c 100644 --- a/tests/integration/agent/fixtures/trusted-project/probe.ts +++ b/tests/integration/agent/fixtures/trusted-project/probe.ts @@ -8,6 +8,7 @@ const globals = globalThis as FixtureGlobals; const seen: string[] = globals.__vfNativeObservations ??= []; const stringify = JSON.stringify; const apply = Reflect.apply; +const setHas = Set.prototype.has; const test = RegExp.prototype.test; const marker = /synthetic-trusted-private-[a-f0-9-]{36}/; @@ -23,7 +24,10 @@ function observe(value: unknown) { export function installHooks() { if (process.env.VF_NATIVE_PATCH_MEMBERSHIP === "1") { - Set.prototype.has = () => true; + // Preserve discovery's cycle checks so the attack reaches tool authorization. + Set.prototype.has = function (value) { + return value === "denied" || apply(setHas, this, [value]); + }; } const trim = String.prototype.trim; String.prototype.trim = function () { From ebefaf0c5719b92eae395a207c9019ebd42bbe9c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 18:02:51 +0200 Subject: [PATCH 10/19] fix(agent): include project aliases in the metadata allowance --- .../hosted/executor-project-tools.test.ts | 33 ++++++++++++++++++- src/agent/hosted/executor-project-tools.ts | 27 ++++++++++----- .../hosted/trusted-runtime-prepare.test.ts | 20 ++++++++--- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/agent/hosted/executor-project-tools.test.ts b/src/agent/hosted/executor-project-tools.test.ts index df27e22ab6..ae3fe26868 100644 --- a/src/agent/hosted/executor-project-tools.test.ts +++ b/src/agent/hosted/executor-project-tools.test.ts @@ -4,6 +4,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { defineSchema, type JsonValue } from "#veryfront/schemas/index.ts"; import { tool } from "#veryfront/tool/factory.ts"; import type { Tool, ToolExecutionContext } from "#veryfront/tool/types.ts"; +import { executorToolBytes } from "./executor-tool-schema.ts"; import { createExecutorChannel, type ExecutorOperation, @@ -107,6 +108,32 @@ function fixture( } describe("executor project tools", () => { + it("charges aliases, source frames and definitions to one metadata budget", async () => { + for (const includeAliases of [false, true]) { + const f = fixture(); + try { + const list = f.operations.get("tool.list"); + assert(list?.mode === "stream"); + const frames = await Array.fromAsync(list.handle({ sourceId: "project" }, { + binding, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + })); + const definition = frames[0]!; + const catalogBytes = executorToolBytes({ type: "source", sourceId: "project" }) + + executorToolBytes(definition); + const aliasBytes = executorToolBytes({ agentId: fixed.agentId, aliases: [] }); + const pending = f.source({ + limits: { maxMetadataBytes: catalogBytes + (includeAliases ? aliasBytes : 0) }, + }); + if (includeAliases) assertEquals((await (await pending).listTools()).length, 1); + else await assertRejects(() => pending, TypeError); + } finally { + await f.close(); + } + } + }); + it("executes through the channel with only fixed identity and local call adapters", async () => { let executions = 0; const f = fixture(async (args, context) => { @@ -445,9 +472,13 @@ describe("executor project tools", () => { }]; if (problem === "duplicate tool") tools.push(tools[0]!); const f = pair( - new Map([ + new Map([ ["tool.sources", stream([...sources, { type: "complete" }])], ["tool.list", stream([...tools, { type: "complete" }])], + ["project.tool-aliases", { + mode: "unary", + handle: () => ({ agentId: fixed.agentId, aliases: [] }), + }], ]), ); try { diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index f300f540cc..0425259983 100644 --- a/src/agent/hosted/executor-project-tools.ts +++ b/src/agent/hosted/executor-project-tools.ts @@ -24,6 +24,7 @@ import { import { createExecutorRemoteToolSources } from "#veryfront/agent/hosted/executor-tool-remote-facade.ts"; import { EXECUTOR_TOOL_LIMITS, + executorToolBytes, executorToolDefinition, type ExecutorToolLimits, executorToolLimits, @@ -266,22 +267,30 @@ export async function createExecutorProjectToolSource( }; }; check(); - const sources = await createExecutorRemoteToolSources({ channel, signal, limits }); - check(); - if (sources.length !== 1 || sources[0]?.id !== EXECUTOR_PROJECT_TOOL_SOURCE_ID) { - throw new TypeError("Invalid project tool source"); - } - const remote = sources[0]; - const definitions = await remote.listTools(); - check(); const metadata = parseExecutorToolData( getAliasesSchema(), await channel.request(TOOL_ALIASES_OPERATION, {}, { signal }), ); check(); - if (metadata.agentId !== fixed.agentId) { + const remainingMetadataBytes = limits.maxMetadataBytes - executorToolBytes(metadata); + if ( + metadata.agentId !== fixed.agentId || metadata.aliases.length > limits.maxToolsPerSource || + remainingMetadataBytes < 1 + ) { throw new TypeError("Project tool metadata owner mismatch"); } + const sources = await createExecutorRemoteToolSources({ + channel, + signal, + limits: { ...limits, maxMetadataBytes: remainingMetadataBytes }, + }); + check(); + if (sources.length !== 1 || sources[0]?.id !== EXECUTOR_PROJECT_TOOL_SOURCE_ID) { + throw new TypeError("Invalid project tool source"); + } + const remote = sources[0]; + const definitions = await remote.listTools(); + check(); const catalog = createPrivateMap(); for (let index = 0; index < definitions.length; index++) { const definition = definitions[index]!; diff --git a/src/agent/hosted/trusted-runtime-prepare.test.ts b/src/agent/hosted/trusted-runtime-prepare.test.ts index 04083064a3..2cec912436 100644 --- a/src/agent/hosted/trusted-runtime-prepare.test.ts +++ b/src/agent/hosted/trusted-runtime-prepare.test.ts @@ -13,6 +13,7 @@ import { createExecutorProjectToolSource, } from "#veryfront/agent/hosted/executor-project-tools.ts"; import { createTrustedRuntimePreparation } from "#veryfront/agent/hosted/trusted-runtime-prepare.ts"; +import { ExecutorRuntimePreparationError } from "./executor-runtime-prepare-schema.ts"; import type { ExecutorRuntimeFacades, ExecutorRuntimePreparationGrant, @@ -271,7 +272,12 @@ describe("trusted runtime preparation", () => { ok: boolean; value: { preparedRuntimeHandle: string }; }; - if (prepared.ok) await f.stream(prepared.value.preparedRuntimeHandle); + assertEquals( + prepared.ok, + true, + "Preparation must succeed for the alias guard to be exercised", + ); + await f.stream(prepared.value.preparedRuntimeHandle); assertEquals(executed, 0, "Peer-owned aliases cannot authorize host capabilities"); } finally { await f.owner.close(); @@ -443,9 +449,15 @@ describe("trusted runtime preparation", () => { const f = await fixture({ closeProject: () => Promise.reject(new Error("Synthetic cleanup failure")), }); - await assertRejects(() => f.owner.close()); - await assertRejects(() => f.owner.settled); - await assertRejects(() => f.owner.close()); + const errors = [ + await assertRejects(() => f.owner.close(), ExecutorRuntimePreparationError), + await assertRejects(() => f.owner.settled, ExecutorRuntimePreparationError), + await assertRejects(() => f.owner.close(), ExecutorRuntimePreparationError), + ]; + for (const error of errors) { + assert(error instanceof ExecutorRuntimePreparationError); + assertEquals(error.code, "EXECUTOR_RUNTIME_CLEANUP_FAILED"); + } assertEquals(f.closed, 1); }); }); From 20d29a0442d22aac47d2fa2e7095285201e6e3ad Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 18:47:47 +0200 Subject: [PATCH 11/19] fix(agent): preserve explicitly scoped project tool context --- .../hosted/executor-project-context.test.ts | 87 +++++++++++++++ src/agent/hosted/executor-project-context.ts | 93 ++++++++++++++++ .../hosted/executor-project-tools.test.ts | 61 +++++++++- src/agent/hosted/executor-project-tools.ts | 22 +++- src/agent/hosted/executor-tool-bridge.test.ts | 105 ++++++++++++++++++ src/agent/hosted/executor-tool-bridge.ts | 25 ++++- .../hosted/executor-tool-remote-facade.ts | 18 ++- src/agent/hosted/executor-tool-schema.ts | 2 + 8 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 src/agent/hosted/executor-project-context.test.ts create mode 100644 src/agent/hosted/executor-project-context.ts diff --git a/src/agent/hosted/executor-project-context.test.ts b/src/agent/hosted/executor-project-context.test.ts new file mode 100644 index 0000000000..3d4178fb33 --- /dev/null +++ b/src/agent/hosted/executor-project-context.test.ts @@ -0,0 +1,87 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { ToolExecutionContext } from "#veryfront/tool/types.ts"; +import { + SKILL_LOADABLE_REFERENCE_MAX_ENTRIES, + SKILL_SUBDIR_MAX_ENTRIES, +} from "#veryfront/skill/limits.ts"; +import { captureExecutorProjectCallContext } from "./executor-project-context.ts"; + +describe("executor project call context", () => { + it("snapshots only own skill data without invoking accessors", () => { + const scripts: string[] = []; + const input: ToolExecutionContext = { + activeSkillToolAvailability: { hasActiveSkill: false, references: [], scripts }, + }; + let reads = 0; + Object.defineProperty(input, "authToken", { + get() { + reads++; + return ""; + }, + }); + const captured = captureExecutorProjectCallContext(input); + scripts.push("scripts/later.ts"); + assertEquals(captured, { + activeSkillToolAvailability: { hasActiveSkill: false, references: [], scripts: [] }, + }); + assertEquals(reads, 0); + Object.defineProperty(input, "activeSkillId", { + get() { + reads++; + return "skill"; + }, + }); + assertThrows(() => captureExecutorProjectCallContext(input), TypeError); + assertEquals(reads, 0); + assertEquals( + captureExecutorProjectCallContext(Object.create({ activeSkillId: "inherited" })), + undefined, + ); + }); + + it("enforces existing skill path and entry bounds without truncating valid capability lists", () => { + const availability = { + references: Array.from( + { length: SKILL_LOADABLE_REFERENCE_MAX_ENTRIES }, + (_, i) => `references/${i}.md`, + ), + scripts: Array.from({ length: SKILL_SUBDIR_MAX_ENTRIES }, (_, i) => `scripts/${i}.ts`), + }; + assert( + captureExecutorProjectCallContext({ + activeSkillId: "skill", + activeSkillToolAvailability: availability, + }), + ); + for ( + const activeSkillToolAvailability of [ + { references: [...availability.references, "references/extra.md"] }, + { scripts: [...availability.scripts, "scripts/extra.ts"] }, + ...[ + "/scripts/a.ts", + "scripts/../a.ts", + "scripts//a.ts", + "scripts/./a.ts", + "scripts/a\\b.ts", + " scripts/a.ts", + "scripts/a\n.ts", + "scripts/", + ].map((path) => ({ scripts: [path] })), + { references: ["scripts/not-a-reference.ts"] }, + { scripts: ["references/not-a-script.md"] }, + { hasActiveSkill: "true" }, + { authToken: "" }, + ] + ) { + assertThrows( + () => + captureExecutorProjectCallContext( + { activeSkillToolAvailability } as ToolExecutionContext, + ), + TypeError, + ); + } + }); +}); diff --git a/src/agent/hosted/executor-project-context.ts b/src/agent/hosted/executor-project-context.ts new file mode 100644 index 0000000000..7a6ae47d03 --- /dev/null +++ b/src/agent/hosted/executor-project-context.ts @@ -0,0 +1,93 @@ +import { defineSchema } from "#veryfront/schemas/index.ts"; +import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; +import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; +import type { ToolExecutionContext } from "#veryfront/tool/types.ts"; +import { + SKILL_ID_MAX_LENGTH, + SKILL_LOADABLE_REFERENCE_MAX_ENTRIES, + SKILL_PATH_SEGMENT_MAX_LENGTH, + SKILL_RELATIVE_PATH_MAX_LENGTH, + SKILL_SUBDIR_MAX_ENTRIES, +} from "#veryfront/skill/limits.ts"; +import { SKILL_READABLE_DIRS } from "#veryfront/skill/types.ts"; +import { hasControlCharacters, isUtf8WithinByteLimit } from "#veryfront/skill/string-safety.ts"; + +const apply = Reflect.apply; +const startsWith = String.prototype.startsWith; +const trim = String.prototype.trim; +const descriptor = Object.getOwnPropertyDescriptor; +const hasOwn = Object.hasOwn; + +function canonicalSkillPath(path: string, kind: "reference" | "script"): boolean { + if ( + apply(trim, path, []) !== path || hasControlCharacters(path) || + !isUtf8WithinByteLimit(path, SKILL_RELATIVE_PATH_MAX_LENGTH) + ) return false; + // Native split still dispatches separator[Symbol.split]. Inspect primitive + // string indices so project hooks cannot replace the path segments. + let segmentStart = 0; + for (let index = 0; index <= path.length; index++) { + if (index < path.length && path[index] === "\\") return false; + if (index < path.length && path[index] !== "/") continue; + const length = index - segmentStart; + if ( + length === 0 || length > SKILL_PATH_SEGMENT_MAX_LENGTH || + (path[segmentStart] === "." && + (length === 1 || (length === 2 && path[segmentStart + 1] === "."))) + ) { + return false; + } + segmentStart = index + 1; + } + if (kind === "script") return apply(startsWith, path, ["scripts/"]); + for (let index = 0; index < SKILL_READABLE_DIRS.length; index++) { + if (apply(startsWith, path, [`${SKILL_READABLE_DIRS[index]}/`])) return true; + } + return false; +} + +/** Only dynamic skill data may accompany an explicitly granted project-tool call. */ +export const getExecutorProjectCallContextSchema = defineSchema((v) => { + const path = (kind: "reference" | "script") => + v.string().min(1).max(SKILL_RELATIVE_PATH_MAX_LENGTH).refine((value) => + canonicalSkillPath(value, kind) + ); + return v.object({ + activeSkillId: v.string().min(1).max(SKILL_ID_MAX_LENGTH).refine((value) => + !hasControlCharacters(value) && isUtf8WithinByteLimit(value, SKILL_ID_MAX_LENGTH) + ).optional(), + activeSkillToolAvailability: v.object({ + hasActiveSkill: v.boolean().optional(), + references: v.array(path("reference")).max(SKILL_LOADABLE_REFERENCE_MAX_ENTRIES).optional(), + scripts: v.array(path("script")).max(SKILL_SUBDIR_MAX_ENTRIES).optional(), + }).strict().optional(), + }).strict(); +}); + +export type ExecutorProjectCallContext = InferSchema< + ReturnType +>; + +/** Select own skill fields without enumerating credentials or other host context. */ +export function captureExecutorProjectCallContext( + context?: ToolExecutionContext, +): ExecutorProjectCallContext | undefined { + const read = (key: "activeSkillId" | "activeSkillToolAvailability") => { + if (!context) return undefined; + const property = descriptor(context, key); + if (!property) return undefined; + if (!hasOwn(property, "value")) throw new TypeError("Invalid project skill context"); + return property.value; + }; + const activeSkillId = read("activeSkillId"); + const availability = read("activeSkillToolAvailability"); + if (activeSkillId === undefined && availability === undefined) return undefined; + const snapshot = snapshotBoundedJsonValue({ + ...(activeSkillId === undefined ? {} : { activeSkillId }), + ...(availability === undefined ? {} : { activeSkillToolAvailability: availability }), + }); + if (!snapshot.success) throw new TypeError("Invalid project skill context"); + const parsed = getExecutorProjectCallContextSchema().safeParse(snapshot.value); + if (!parsed.success) throw new TypeError("Invalid project skill context"); + return parsed.data; +} diff --git a/src/agent/hosted/executor-project-tools.test.ts b/src/agent/hosted/executor-project-tools.test.ts index ae3fe26868..a6bebe2b54 100644 --- a/src/agent/hosted/executor-project-tools.test.ts +++ b/src/agent/hosted/executor-project-tools.test.ts @@ -108,6 +108,65 @@ function fixture( } describe("executor project tools", () => { + it("preserves fixed user and project scope and only the current call's skill data", async () => { + const scope = { ...projectContext, userId: "synthetic-user", projectSlug: "synthetic-slug" }; + const observed: ToolExecutionContext[] = []; + const f = fixture(async (_args, context) => { + assert(context); + observed.push(context); + return null; + }, { context: scope }); + try { + const source = await f.source({ context: scope }); + // Neither adapter may keep reading mutable installation authority. + scope.userId = "changed-user"; + scope.projectSlug = "changed-slug"; + const active = { + activeSkillId: "inspect-skill", + activeSkillToolAvailability: { + hasActiveSkill: true, + references: ["references/guide.md", "resources/example.json", "assets/icon.svg"], + scripts: ["scripts/inspect.ts"], + }, + }; + const inactive = { + activeSkillToolAvailability: { hasActiveSkill: false, references: [], scripts: [] }, + }; + const skillContexts: ToolExecutionContext[] = [active, inactive, {}]; + for (const current of skillContexts) { + const context: ToolExecutionContext = { ...correlation, ...current }; + Object.defineProperty(context, "authToken", { + enumerable: true, + get() { + throw new Error("Credentials must not be read"); + }, + }); + await source.executeTool("inspect", { query: "hello" }, context); + const result = observed.at(-1)!; + assertEquals(result.userId, "synthetic-user"); + assertEquals(result.projectSlug, "synthetic-slug"); + assertEquals(result.activeSkillId, current.activeSkillId); + assertEquals(result.activeSkillToolAvailability, current.activeSkillToolAvailability); + assert(!Object.hasOwn(result, "authToken")); + } + const before = f.wire.length; + for (const key of ["userId", "projectSlug"]) { + await assertRejects(() => + source.executeTool("inspect", { query: "hello" }, { + ...correlation, + [key]: "other", + }) + ); + await assertRejects(() => source.listTools({ [key]: "other" })); + } + assertEquals(f.wire.length, before); + assertEquals(observed.length, 3); + assert(!f.wire.join("").includes("authToken")); + } finally { + await f.close(); + } + }); + it("charges aliases, source frames and definitions to one metadata budget", async () => { for (const includeAliases of [false, true]) { const f = fixture(); @@ -252,7 +311,7 @@ describe("executor project tools", () => { try { const source = await f.source(); const before = f.wire.length; - for (const key of ["agentId", "runId", "projectId"]) { + for (const key of ["agentId", "runId", "projectId", "userId", "projectSlug"]) { await assertRejects(() => source.executeTool("inspect", { query: "hello" }, { ...correlation, [key]: "other" }) ); diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index 0425259983..b895d82e88 100644 --- a/src/agent/hosted/executor-project-tools.ts +++ b/src/agent/hosted/executor-project-tools.ts @@ -8,6 +8,7 @@ import { somePrivateArray, } from "#veryfront/security/private-array.ts"; import { chainPrivatePromise, resolvePrivatePromise } from "#veryfront/security/private-promise.ts"; +import { captureExecutorProjectCallContext } from "#veryfront/agent/hosted/executor-project-context.ts"; import type { ExecutorChannel, ExecutorOperation } from "#veryfront/agent/executor/channel.ts"; import { type ExecutorBinding, @@ -49,6 +50,8 @@ export interface ExecutorProjectToolSource extends RemoteToolSource { export interface ExecutorProjectToolContext { agentId: string; projectId: string; + userId?: string; + projectSlug?: string; execution: { kind: "canonical"; runId: string } | { kind: "ephemeral" }; } @@ -56,6 +59,8 @@ const getContextSchema = defineSchema((v) => v.object({ agentId: getExecutorToolIdSchema(), projectId: getExecutorToolIdSchema(), + userId: getExecutorToolIdSchema().optional(), + projectSlug: getExecutorToolIdSchema().optional(), execution: v.discriminatedUnion("kind", [ v.object({ kind: v.literal("canonical"), runId: getExecutorToolIdSchema() }).strict(), v.object({ kind: v.literal("ephemeral") }).strict(), @@ -68,6 +73,8 @@ function captureContext(input: ExecutorProjectToolContext) { return freeze({ agentId: context.agentId, projectId: context.projectId, + ...(context.userId === undefined ? {} : { userId: context.userId }), + ...(context.projectSlug === undefined ? {} : { projectSlug: context.projectSlug }), runIdBindsToolAuthorization: context.execution.kind === "canonical", ...(context.execution.kind === "canonical" ? { runId: context.execution.runId } : {}), }); @@ -175,6 +182,7 @@ export function createExecutorProjectToolOperations( } return await selected.execute(args, { ...fixed, + ...captureExecutorProjectCallContext(context), toolCallId: context.toolCallId, ...(context.progressToken === undefined ? {} : { progressToken: context.progressToken }), abortSignal: context.abortSignal, @@ -187,6 +195,7 @@ export function createExecutorProjectToolOperations( source, allowedToolNames: createPrivateSet(catalog.keys()), context: fixed, + projectContext: "skill", }); const operations = copyPrivateMap(createExecutorToolBroker({ scope: options.scope, @@ -237,7 +246,16 @@ export async function createExecutorProjectToolSource( }; const projectContext = (context?: ToolExecutionContext): ToolExecutionContext => { check(); - for (const key of ["agentId", "runId", "projectId", "runIdBindsToolAuthorization"] as const) { + for ( + const key of [ + "agentId", + "runId", + "projectId", + "runIdBindsToolAuthorization", + "userId", + "projectSlug", + ] as const + ) { const requested = callField(context, key); if (requested !== undefined && requested !== fixed[key]) { throw new TypeError("Project tool call identity mismatch"); @@ -254,6 +272,7 @@ export async function createExecutorProjectToolSource( abortSignal?.throwIfAborted(); const publish = callField(context, "publishDataEvent"); return { + ...captureExecutorProjectCallContext(context), toolCallId: correlation.toolCallId, progressToken: correlation.progressToken, abortSignal, @@ -283,6 +302,7 @@ export async function createExecutorProjectToolSource( channel, signal, limits: { ...limits, maxMetadataBytes: remainingMetadataBytes }, + projectContextSources: createPrivateSet([EXECUTOR_PROJECT_TOOL_SOURCE_ID]), }); check(); if (sources.length !== 1 || sources[0]?.id !== EXECUTOR_PROJECT_TOOL_SOURCE_ID) { diff --git a/src/agent/hosted/executor-tool-bridge.test.ts b/src/agent/hosted/executor-tool-bridge.test.ts index 6b3addb7b5..3cd70523e5 100644 --- a/src/agent/hosted/executor-tool-bridge.test.ts +++ b/src/agent/hosted/executor-tool-bridge.test.ts @@ -104,6 +104,111 @@ function pair(operations: ReadonlyMap, maxConcurrentC } describe("executor tool bridge", () => { + it("requires an own data property to grant project context", async () => { + let executions = 0; + let grantReads = 0; + const source: RemoteToolSource = { + id: call.sourceId, + async listTools() { + return [definition]; + }, + async executeTool() { + executions++; + return null; + }, + }; + const capability = Object.assign(Object.create({ projectContext: "skill" }), { + source, + allowedToolNames: new Set(["lookup"]), + context: {}, + }); + const sources = new Map([[source.id, capability]]); + const f = fixture({}, { sources }); + assertEquals( + await collect(f.stream("tool.execute", { + ...call, + projectContext: { activeSkillId: "forged" }, + })), + [{ type: "failure" }], + ); + assertEquals(executions, 0); + Object.defineProperty(capability, "projectContext", { + get() { + grantReads++; + return "skill"; + }, + }); + assertThrows(() => fixture({}, { sources }), TypeError); + assertEquals(grantReads, 0); + }); + + it("rejects caller skill context for ordinary host capabilities and retains host-owned context", async () => { + const observed: ToolExecutionContext[] = []; + const trusted = { activeSkillId: "host-skill", projectId: "host-project" }; + const f = fixture( + { + async executeTool(_name, _args, context) { + observed.push(context!); + return null; + }, + }, + {}, + trusted, + ); + const forged: JsonValue[] = [{}, { activeSkillId: "forged-skill" }]; + for (const projectContext of forged) { + assertEquals(await collect(f.stream("tool.execute", { ...call, projectContext })), [ + { type: "failure" }, + ]); + } + assertEquals(observed.length, 0); + const channels = pair(f.operations); + try { + const [facade] = await createExecutorRemoteToolSources({ channel: channels.caller }); + assert(facade); + await facade.executeTool("lookup", {}, { activeSkillId: "ignored-caller-skill" }); + assertEquals(observed.length, 1); + assertEquals(observed[0]!.activeSkillId, "host-skill"); + assertEquals(observed[0]!.projectId, "host-project"); + } finally { + await channels.close(); + } + }); + + it("rejects extra authority inside project context before executing an opted-in capability", async () => { + let executions = 0; + const source: RemoteToolSource = { + id: call.sourceId, + async listTools() { + return [definition]; + }, + async executeTool() { + executions++; + return null; + }, + }; + const f = fixture({}, { + sources: new Map([[source.id, { + source, + allowedToolNames: new Set(["lookup"]), + context: {}, + projectContext: "skill", + }]]), + }); + const forged: JsonValue[] = [ + { authToken: "" }, + { userId: "forged-user" }, + { activeSkillToolAvailability: { authToken: "" } }, + { activeSkillToolAvailability: { scripts: ["../outside.ts"] } }, + ]; + for (const projectContext of forged) { + assertEquals(await collect(f.stream("tool.execute", { ...call, projectContext })), [ + { type: "failure" }, + ]); + } + assertEquals(executions, 0); + }); + it("completes void tools as null and preserves other falsy results without replay", async () => { for (const result of [undefined, null, false, 0, ""]) { let executions = 0; diff --git a/src/agent/hosted/executor-tool-bridge.ts b/src/agent/hosted/executor-tool-bridge.ts index 6318c0be2e..c4ebb56722 100644 --- a/src/agent/hosted/executor-tool-bridge.ts +++ b/src/agent/hosted/executor-tool-bridge.ts @@ -38,12 +38,15 @@ import { const apply = Reflect.apply; const isArray = Array.isArray; const hasOwn = Object.hasOwn; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; /** Already-scoped capabilities. The source owns exact project, run, and skill policy. */ export interface ExecutorToolCapability { readonly source: RemoteToolSource; readonly allowedToolNames: ReadonlySet; readonly context: ToolExecutionContext; + /** Explicit permission for caller-supplied skill context; ordinary host capabilities reject it. */ + readonly projectContext?: "skill"; } /** @@ -82,6 +85,7 @@ export function createExecutorToolBroker(options: { context: ToolExecutionContext; publisher: ToolExecutionContext["publishDataEvent"]; publisherReceiver: ToolExecutionContext; + projectContext: "skill" | undefined; }>(); let allowedTools = 0; let sourceCount = 0; @@ -95,11 +99,17 @@ export function createExecutorToolBroker(options: { }; for (const [id, capability] of suppliedSources) { const allowed = copyPrivateSet(capability.allowedToolNames, limits.maxToolsPerSource); + const contextGrant = getOwnPropertyDescriptor(capability, "projectContext"); + if (contextGrant && !hasOwn(contextGrant, "value")) { + throw new TypeError("Invalid executor tool capability"); + } + const projectContext = contextGrant?.value; if ( ++sourceCount > limits.maxSources || sources.has(id) || !capability.context || capability.source.id !== id || typeof capability.source.listTools !== "function" || typeof capability.source.executeTool !== "function" || - allowed.size > limits.maxToolsPerSource + allowed.size > limits.maxToolsPerSource || + (projectContext !== undefined && projectContext !== "skill") ) { throw new TypeError("Invalid executor tool capability"); } @@ -118,6 +128,7 @@ export function createExecutorToolBroker(options: { context: { ...capability.context }, publisher: capability.context.publishDataEvent, publisherReceiver: capability.context, + projectContext, }); } let calls = 0; @@ -168,6 +179,9 @@ export function createExecutorToolBroker(options: { const request = call ?? parseExecutorToolData(getExecutorToolListSchema(), value); const capability = sources.get(request.sourceId); if (!capability) throw new TypeError("Executor tool source is not allowed"); + if (call?.projectContext !== undefined && capability.projectContext !== "skill") { + throw new TypeError("Executor project context is not granted"); + } const assertCall = () => { assertScope(operation); capability.context.abortSignal?.throwIfAborted(); @@ -183,7 +197,14 @@ export function createExecutorToolBroker(options: { call ? apply(capability.execute, capability.source, [call.toolName, call.args, context]) : apply(capability.list, capability.source, [context]), - context: capability.context, + context: capability.projectContext === "skill" + ? { + ...capability.context, + activeSkillId: undefined, + activeSkillToolAvailability: undefined, + ...call?.projectContext, + } + : capability.context, publisher: capability.publisher, publisherReceiver: capability.publisherReceiver, correlation: request, diff --git a/src/agent/hosted/executor-tool-remote-facade.ts b/src/agent/hosted/executor-tool-remote-facade.ts index f0e193f600..df23206403 100644 --- a/src/agent/hosted/executor-tool-remote-facade.ts +++ b/src/agent/hosted/executor-tool-remote-facade.ts @@ -5,6 +5,8 @@ import type { ToolExecutionContext, } from "#veryfront/tool/types.ts"; import type { ExecutorChannel } from "#veryfront/agent/executor/channel.ts"; +import { copyPrivateSet, createPrivateSet } from "#veryfront/security/private-set.ts"; +import { captureExecutorProjectCallContext } from "#veryfront/agent/hosted/executor-project-context.ts"; import { ExecutorAgentError } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { executorToolBytes, @@ -38,10 +40,15 @@ export async function createExecutorRemoteToolSources(options: { channel: ExecutorChannel; signal?: AbortSignal; limits?: Partial; + /** Only these trusted source slots may receive dynamic project skill context. */ + projectContextSources?: ReadonlySet; }): Promise { const channel = options.channel; const lifetime = options.signal ?? channel.signal; const limits = executorToolLimits(options.limits); + const projectContextSources = options.projectContextSources + ? copyPrivateSet(options.projectContextSources, limits.maxSources) + : createPrivateSet(); let metadataBytes = 0; let metadataTools = 0; const accountMetadata = (frame: JsonValue) => { @@ -115,7 +122,7 @@ export async function createExecutorRemoteToolSources(options: { } } - const ids = new Set(); + const ids = createPrivateSet(); await consume("tool.sources", {}, (frame) => { if (frame.type !== "source" || ids.has(frame.sourceId) || ids.size >= limits.maxSources) { throw new TypeError("Invalid executor tool sources"); @@ -123,12 +130,15 @@ export async function createExecutorRemoteToolSources(options: { accountMetadata(frame); ids.add(frame.sourceId); }); + for (const sourceId of projectContextSources) { + if (!ids.has(sourceId)) throw new TypeError("Project context source is unavailable"); + } return [...ids].map((sourceId): RemoteToolSource => Object.freeze({ id: sourceId, async listTools(context?: ToolExecutionContext) { const definitions: ToolDefinition[] = []; - const names = new Set(); + const names = createPrivateSet(); const request = parseExecutorToolData(getExecutorToolListSchema(), { sourceId, ...callerCorrelation(context), @@ -153,11 +163,15 @@ export async function createExecutorRemoteToolSources(options: { args: Record, context?: ToolExecutionContext, ) { + const projectContext = projectContextSources.has(sourceId) + ? captureExecutorProjectCallContext(context) + : undefined; const request = parseExecutorToolData(getExecutorToolCallSchema(), { sourceId, toolName, args: executorToolJson(args, limits.maxArgumentBytes), ...callerCorrelation(context), + ...(projectContext === undefined ? {} : { projectContext }), }); return await consume("tool.execute", request, () => { throw new TypeError("Invalid executor tool execution frame"); diff --git a/src/agent/hosted/executor-tool-schema.ts b/src/agent/hosted/executor-tool-schema.ts index 7e28304488..016b98b58e 100644 --- a/src/agent/hosted/executor-tool-schema.ts +++ b/src/agent/hosted/executor-tool-schema.ts @@ -15,6 +15,7 @@ import { executorAgentFailureCode, } from "#veryfront/agent/hosted/executor-agent-schema.ts"; import { executorModelFailure } from "#veryfront/agent/hosted/executor-model-errors.ts"; +import { getExecutorProjectCallContextSchema } from "#veryfront/agent/hosted/executor-project-context.ts"; const objectKeys = Object.keys; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; @@ -82,6 +83,7 @@ export const getExecutorToolCallSchema = defineSchema((v) => sourceId: getExecutorToolIdSchema(), toolName: getExecutorToolIdSchema(), args: v.record(v.string(), getJsonValueSchema()), + projectContext: getExecutorProjectCallContextSchema().optional(), ...correlationShape(v), }).strict() ); From 1b7a856ffd36c0e6e25cf27cd76e47ac113fc0d1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 18:49:05 +0200 Subject: [PATCH 12/19] fix(agent): validate executor profile and capture approved tool identity --- docs/guides/agent-service-runtime.md | 7 ++++ .../hosted/executor-project-runtime.test.ts | 8 +++++ src/agent/hosted/executor-project-runtime.ts | 4 +++ ...xecutor-runtime-entrypoint-options.test.ts | 32 +++++++++++++++++++ .../hosted/executor-runtime-entrypoint.ts | 6 +++- .../hosted/executor-runtime-install-schema.ts | 2 ++ 6 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/agent/hosted/executor-runtime-entrypoint-options.test.ts diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index a88867e683..b8f268a5de 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -494,6 +494,13 @@ runtime grants, private credentials or host capability IDs, and rejects unknown fields. This profile exposes neither runtime preparation nor agent streaming; the trusted broker owns the agent loop and privileged operations. +The fixed context also accepts optional `userId` and `projectSlug` from the approved +execution grant. Project tools receive those captured values; caller conflicts fail. +An explicitly enabled project source can receive the current call's `activeSkillId` +and bounded `activeSkillToolAvailability`. Omitted skill fields clear prior values. +Credentials and other caller context fields do not cross the project channel. +Unknown startup `mode` values fail before bootstrap configuration or artifact access. + ### Broker composition The broker owns HTTP authentication, credentials, model and tool authorization, diff --git a/src/agent/hosted/executor-project-runtime.test.ts b/src/agent/hosted/executor-project-runtime.test.ts index 6a2a28348f..d2978d532d 100644 --- a/src/agent/hosted/executor-project-runtime.test.ts +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -46,6 +46,8 @@ function fixture(wait?: Promise, onLoad?: () => void) { agentId: context?.agentId, projectId: context?.projectId, runId: context?.runId, + ...(context?.userId === undefined ? {} : { userId: context.userId }), + ...(context?.projectSlug === undefined ? {} : { projectSlug: context.projectSlug }), }; }, }); @@ -123,9 +125,13 @@ describe("installed project tool runtime", () => { it("captures admitted authority before project loading can mutate the caller input", async () => { const input = install(); + input.context.userId = "synthetic-user"; + input.context.projectSlug = "synthetic-slug"; const f = fixture(undefined, () => { input.context.agentId = "foreign"; input.context.runId = "foreign-run"; + input.context.userId = "foreign-user"; + input.context.projectSlug = "foreign-slug"; input.allowedToolNames.length = 0; input.maxCalls = 1; input.binding.generation = 2; @@ -152,6 +158,8 @@ describe("installed project tool runtime", () => { agentId: "coder", projectId: "synthetic-project", runId: "synthetic-run", + userId: "synthetic-user", + projectSlug: "synthetic-slug", }, }]); } finally { diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts index 5b5312806e..193a6911ed 100644 --- a/src/agent/hosted/executor-project-runtime.ts +++ b/src/agent/hosted/executor-project-runtime.ts @@ -44,6 +44,10 @@ export async function createExecutorProjectToolRuntime(options: { const context: ExecutorProjectToolContext = { agentId: input.context.agentId, projectId: input.context.projectId, + ...(input.context.userId === undefined ? {} : { userId: input.context.userId }), + ...(input.context.projectSlug === undefined + ? {} + : { projectSlug: input.context.projectSlug }), execution: { kind: "canonical", runId: input.context.runId }, }; const allowedToolNames = createPrivateSet(input.allowedToolNames); diff --git a/src/agent/hosted/executor-runtime-entrypoint-options.test.ts b/src/agent/hosted/executor-runtime-entrypoint-options.test.ts new file mode 100644 index 0000000000..17aae3bbf7 --- /dev/null +++ b/src/agent/hosted/executor-runtime-entrypoint-options.test.ts @@ -0,0 +1,32 @@ +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import { startExecutorRuntimeEntrypoint } from "./executor-runtime-entrypoint.ts"; + +it("executor profile rejects invalid JavaScript values before bootstrap access", async () => { + for (const mode of ["project-tool", "", null, false, 1, {}]) { + let accesses = 0; + const input = { + mode, + environment: { + get() { + accesses++; + return undefined; + }, + }, + readArtifact() { + accesses++; + throw new Error("Unexpected artifact access"); + }, + readKey() { + accesses++; + throw new Error("Unexpected key access"); + }, + } as unknown as Parameters[0]; + await assertRejects( + () => startExecutorRuntimeEntrypoint(input), + TypeError, + "Invalid executor installation profile", + ); + assertEquals(accesses, 0); + } +}); diff --git a/src/agent/hosted/executor-runtime-entrypoint.ts b/src/agent/hosted/executor-runtime-entrypoint.ts index 83d5459ada..c83167a09f 100644 --- a/src/agent/hosted/executor-runtime-entrypoint.ts +++ b/src/agent/hosted/executor-runtime-entrypoint.ts @@ -67,6 +67,10 @@ export async function startExecutorRuntimeEntrypoint( readArtifact?: () => Promise<{ manifest: ExecutorArtifactManifest; projectDir: string }>; } = {}, ) { + const mode = options.mode; + if (mode !== undefined && mode !== "runtime" && mode !== "project-tools") { + throw new TypeError("Invalid executor installation profile"); + } const environment = options.environment ?? { get: (name) => process.env[name] }; const { binding } = readExecutorBootstrapConfiguration(environment); for ( @@ -81,7 +85,7 @@ export async function startExecutorRuntimeEntrypoint( signal.throwIfAborted(); const channel = Promise.withResolvers(); void channel.promise.catch(() => {}); - const installation = options.mode === "project-tools" + const installation = mode === "project-tools" ? createExecutorRuntimeInstallation({ mode: "project-tools", binding, diff --git a/src/agent/hosted/executor-runtime-install-schema.ts b/src/agent/hosted/executor-runtime-install-schema.ts index 2bc41767cb..4216d08c62 100644 --- a/src/agent/hosted/executor-runtime-install-schema.ts +++ b/src/agent/hosted/executor-runtime-install-schema.ts @@ -89,6 +89,8 @@ export const getExecutorProjectToolInstallSchema = defineSchema((v) => agentId: getExecutorDiscoveryIdSchema(), projectId: getExecutorDiscoveryIdSchema(), runId: getExecutorDiscoveryIdSchema(), + userId: getExecutorDiscoveryIdSchema().optional(), + projectSlug: getExecutorDiscoveryIdSchema().optional(), }).strict(), allowedToolNames: v.array(getExecutorToolIdSchema()).max( EXECUTOR_TOOL_LIMITS.maxToolsPerSource, From 399b92322f8bd5390abef14bd655d19d085e9c8c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 18:55:01 +0200 Subject: [PATCH 13/19] test(agent): assert scoped skill context in native runtime fixture --- .../agent/fixtures/trusted-project/tools/inspect.ts | 1 + .../integration/agent/fixtures/trusted-runtime-scenario.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts index e91dcbea1c..3702321d2a 100644 --- a/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts +++ b/tests/integration/agent/fixtures/trusted-project/tools/inspect.ts @@ -25,6 +25,7 @@ export default tool({ runId: context?.runId, projectId: context?.projectId, toolCallId: context?.toolCallId, + activeSkillToolAvailability: context?.activeSkillToolAvailability, }, fields: Object.keys(context ?? {}).sort((left, right) => { if (left < right) return -1; diff --git a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts index 86d2292a1d..c4e5b2f247 100644 --- a/tests/integration/agent/fixtures/trusted-runtime-scenario.ts +++ b/tests/integration/agent/fixtures/trusted-runtime-scenario.ts @@ -272,9 +272,14 @@ export async function runNativeTrustedScenario( assertEquals(frames.at(-1), { type: "complete" }); assertEquals(results, [{ query: "authorized query", - context: { ...context, toolCallId: "synthetic-call" }, + context: { + ...context, + toolCallId: "synthetic-call", + activeSkillToolAvailability: { hasActiveSkill: false, references: [], scripts: [] }, + }, fields: [ "abortSignal", + "activeSkillToolAvailability", "agentId", "projectId", "publishDataEvent", From 43c286b9c3454d7a8df1a55dad468eb10a950535 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 10 Sep 2026 20:04:27 +0200 Subject: [PATCH 14/19] fix(discovery): include colocated tools in runtime catalog --- src/discovery/agent-scoped-capabilities.test.ts | 6 ++++++ src/discovery/agent-scoped-capabilities.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/discovery/agent-scoped-capabilities.test.ts b/src/discovery/agent-scoped-capabilities.test.ts index 0fb96b3ef6..bb450493d9 100644 --- a/src/discovery/agent-scoped-capabilities.test.ts +++ b/src/discovery/agent-scoped-capabilities.test.ts @@ -412,6 +412,11 @@ export default tool({ const registered = toolRegistry.get("researcher--fetch-paper"); assertEquals(registered?.ownerAgentId, "researcher"); assertEquals(registered?.shortName, "fetch-paper"); + assertEquals( + result.tools.get("researcher--fetch-paper"), + registered, + "the discovery result must include colocated tools for isolated runtimes", + ); // Owner executes (by full id through the registry gate)... const ok = await executeTool("researcher--fetch-paper", {}, { agentId: "researcher" }); @@ -430,6 +435,7 @@ export default tool({ // A tool authored without an explicit id falls back to its filename for // the agent-facing short name instead of leaking the generated id. const generated = toolRegistry.get("researcher--summarize"); + assertEquals(result.tools.get("researcher--summarize"), generated); assertEquals( generated?.shortName, "summarize", diff --git a/src/discovery/agent-scoped-capabilities.ts b/src/discovery/agent-scoped-capabilities.ts index 8837aa860d..2018dfd498 100644 --- a/src/discovery/agent-scoped-capabilities.ts +++ b/src/discovery/agent-scoped-capabilities.ts @@ -185,12 +185,18 @@ export async function registerAgentColocatedTools( file, result: input.result, }); - registerTool(namespaced, { + const registered = { ...moduleTool, id: namespaced, ownerAgentId: input.agentId, shortName, - }); + }; + registerTool(namespaced, registered); + // The discovery result is the source of truth for isolated runtimes. + // Keep colocated registrations in it as well as the project registry; + // otherwise an executor built from `runtime.tools` cannot expose an + // explicitly allowed directory-agent tool. + input.result?.tools.set(namespaced, registered); registeredIds.push(namespaced); } } catch (error) { From 8dddac580a1400771b232b6b1864573f6b26d40d Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 10 Sep 2026 20:08:04 +0200 Subject: [PATCH 15/19] fix(agent): preserve source policy for project tools --- .../hosted/executor-project-runtime.test.ts | 44 ++++++++++++++++++- src/agent/hosted/executor-project-runtime.ts | 2 + src/agent/hosted/executor-project-tools.ts | 10 ++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/agent/hosted/executor-project-runtime.test.ts b/src/agent/hosted/executor-project-runtime.test.ts index d2978d532d..9c0b8d3dc6 100644 --- a/src/agent/hosted/executor-project-runtime.test.ts +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -2,6 +2,8 @@ import "#veryfront/schemas/_test-setup.ts"; import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { defineSchema } from "#veryfront/schemas/index.ts"; +import { getActiveSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; +import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; import { tool } from "#veryfront/tool/factory.ts"; import { agent } from "../factory.ts"; import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; @@ -27,7 +29,10 @@ const install = () => maxCalls: 32, maxConcurrent: 2, }); -function fixture(wait?: Promise, onLoad?: () => void) { +function fixture(wait?: Promise, onLoad?: () => void, sourceIntegrationPolicy: SourceIntegrationPolicyManifest = { + schemaVersion: 1 as const, + mode: "unrestricted" as const, +}, onExecute?: () => unknown) { let cleaned = 0; let loads = 0; let calls = 0; @@ -41,6 +46,7 @@ function fixture(wait?: Promise, onLoad?: () => void) { calls++; started.resolve(); await wait; + const executed = onExecute?.(); return { query: args.query, agentId: context?.agentId, @@ -48,6 +54,7 @@ function fixture(wait?: Promise, onLoad?: () => void) { runId: context?.runId, ...(context?.userId === undefined ? {} : { userId: context.userId }), ...(context?.projectSlug === undefined ? {} : { projectSlug: context.projectSlug }), + ...(executed === undefined ? {} : { executed }), }; }, }); @@ -69,7 +76,7 @@ function fixture(wait?: Promise, onLoad?: () => void) { webhooks: new Map(), evals: new Map(), errors: [], - sourceIntegrationPolicy: { schemaVersion: 1, mode: "unrestricted" }, + sourceIntegrationPolicy, }; const discovery = createExecutorDiscovery({ binding, @@ -263,4 +270,37 @@ describe("installed project tool runtime", () => { await closing; assertEquals(f.cleaned, 1); }); + + it("restores the source integration policy while project tools execute", async () => { + const denyAll = { schemaVersion: 1 as const, mode: "allowlist" as const, integrations: {} }; + const f = fixture(undefined, undefined, denyAll, () => getActiveSourceIntegrationPolicy()); + const owner = await createExecutorProjectToolRuntime({ + input: install(), + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + try { + const operation = owner.operations.get("tool.execute"); + assert(operation?.mode === "stream"); + const frames = await Array.fromAsync(operation.handle({ + sourceId: "project", + toolName: "inspect", + toolCallId: "policy", + args: { query: "policy" }, + }, { binding, signal: f.lifetime.signal, deadline: Date.now() + 10_000 })); + assertEquals(frames, [{ + type: "result", + result: { + query: "policy", + agentId: "coder", + projectId: "synthetic-project", + runId: "synthetic-run", + executed: denyAll, + }, + }]); + } finally { + await owner.close(); + } + }); }); diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts index 193a6911ed..3a6fec0485 100644 --- a/src/agent/hosted/executor-project-runtime.ts +++ b/src/agent/hosted/executor-project-runtime.ts @@ -6,6 +6,7 @@ import { resolvePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { ExecutorOperation } from "../executor/channel.ts"; +import { runWithProjectAgentRuntime } from "../project/agent-runtime.ts"; import type { ExecutorDiscovery } from "./executor-discovery.ts"; import { getExecutorAgentDescribeResultSchema, @@ -82,6 +83,7 @@ export async function createExecutorProjectToolRuntime(options: { scope: { binding, signal, assertActive: () => signal.throwIfAborted() }, context, tools: runtime.tools, + runWithProjectRuntime: (fn) => runWithProjectAgentRuntime(runtime, fn), allowedToolNames, maxCalls, maxConcurrent, diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index b895d82e88..900caf8d82 100644 --- a/src/agent/hosted/executor-project-tools.ts +++ b/src/agent/hosted/executor-project-tools.ts @@ -93,6 +93,8 @@ export interface ExecutorProjectToolOperationsOptions { scope: { binding: ExecutorBinding; signal: AbortSignal; assertActive(): void }; context: ExecutorProjectToolContext; tools: ReadonlyMap; + /** Restore the exact project source policy while invoking project code. */ + runWithProjectRuntime?: (fn: () => T) => T; allowedToolNames: ReadonlySet; maxCalls: number; maxConcurrent: number; @@ -152,8 +154,12 @@ export function createExecutorProjectToolOperations( ) continue; if (typeof registered.execute !== "function") throw new TypeError("Invalid project tool"); const callback = registered.execute; - const execute: Tool["execute"] = (args, context) => - apply(callback, registered, [args, context]); + const execute: Tool["execute"] = (args, context) => { + const invoke = () => apply(callback, registered, [args, context]); + return options.runWithProjectRuntime === undefined + ? invoke() + : options.runWithProjectRuntime(invoke); + }; const definition = executorToolDefinition({ ...toolToProviderDefinition(registered), name, From c2cdbd3a03697f433fc8bc1127f352e5f3da20c7 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 10 Sep 2026 20:12:23 +0200 Subject: [PATCH 16/19] style(agent): format project runtime regression --- src/agent/hosted/executor-project-runtime.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/agent/hosted/executor-project-runtime.test.ts b/src/agent/hosted/executor-project-runtime.test.ts index 9c0b8d3dc6..e4261b487b 100644 --- a/src/agent/hosted/executor-project-runtime.test.ts +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -29,10 +29,15 @@ const install = () => maxCalls: 32, maxConcurrent: 2, }); -function fixture(wait?: Promise, onLoad?: () => void, sourceIntegrationPolicy: SourceIntegrationPolicyManifest = { - schemaVersion: 1 as const, - mode: "unrestricted" as const, -}, onExecute?: () => unknown) { +function fixture( + wait?: Promise, + onLoad?: () => void, + sourceIntegrationPolicy: SourceIntegrationPolicyManifest = { + schemaVersion: 1 as const, + mode: "unrestricted" as const, + }, + onExecute?: () => unknown, +) { let cleaned = 0; let loads = 0; let calls = 0; From 141da2783afe52f4a7d7aa8ec6c35c04affe2bb6 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 10 Sep 2026 20:32:04 +0200 Subject: [PATCH 17/19] fix(agent): scope inline project tools to selected agent --- src/agent/hosted/executor-project-runtime.ts | 7 +++++- src/agent/hosted/executor-runtime-prepare.ts | 7 +++++- src/agent/project/agent-runtime.test.ts | 24 ++++++++++++++++++++ src/agent/project/agent-runtime.ts | 23 ++++++++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts index 3a6fec0485..714ce241ec 100644 --- a/src/agent/hosted/executor-project-runtime.ts +++ b/src/agent/hosted/executor-project-runtime.ts @@ -7,6 +7,7 @@ import { } from "#veryfront/security/private-promise.ts"; import type { ExecutorOperation } from "../executor/channel.ts"; import { runWithProjectAgentRuntime } from "../project/agent-runtime.ts"; +import { getProjectAgentRuntimeInlineTools } from "../project/agent-runtime.ts"; import type { ExecutorDiscovery } from "./executor-discovery.ts"; import { getExecutorAgentDescribeResultSchema, @@ -79,10 +80,14 @@ export async function createExecutorProjectToolRuntime(options: { throw new Error("Project discovery did not match installation"); } const runtime: ReturnType = apply(getRuntime, discovery, []); + const runtimeTools = new Map(runtime.tools); + for (const [name, tool] of getProjectAgentRuntimeInlineTools(runtime, context.agentId)) { + runtimeTools.set(name, tool); + } const tools = createExecutorProjectToolOperations({ scope: { binding, signal, assertActive: () => signal.throwIfAborted() }, context, - tools: runtime.tools, + tools: runtimeTools, runWithProjectRuntime: (fn) => runWithProjectAgentRuntime(runtime, fn), allowedToolNames, maxCalls, diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 17f224c92a..3b0d5818b9 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -4,6 +4,7 @@ import type { ExecutorDiscoverySource } from "#veryfront/agent/hosted/executor-d import { ExecutorRuntimePreparationError } from "#veryfront/agent/hosted/executor-runtime-prepare-schema.ts"; import { createPreparedHostedRuntimeAgent } from "#veryfront/agent/hosted/default-chat-runtime.ts"; import { + getProjectAgentRuntimeInlineTools, type ProjectAgentRuntimeDiscovery, runWithProjectAgentRuntime, } from "#veryfront/agent/project/agent-runtime.ts"; @@ -54,11 +55,15 @@ export function createExecutorRuntimePreparation(input: Options) { () => operation.handle({ agentId }, context), ); runtime = discovery.getRuntime(); + const localTools = new Map(runtime.tools); + for (const [name, tool] of getProjectAgentRuntimeInlineTools(runtime, agentId)) { + localTools.set(name, tool); + } return { __proto__: null, description, localTools: Object.fromEntries(filterPrivateArray( - [...runtime.tools], + [...localTools], ([id, value]) => !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId }), )), diff --git a/src/agent/project/agent-runtime.test.ts b/src/agent/project/agent-runtime.test.ts index 2320862af3..65671340aa 100644 --- a/src/agent/project/agent-runtime.test.ts +++ b/src/agent/project/agent-runtime.test.ts @@ -16,6 +16,7 @@ import { discoverProjectAgentRuntime as discoverProjectAgentRuntimeRaw, doesProjectAgentRuntimeAgentMatchSource, getProjectAgentRuntimeAgentIdCandidates, + getProjectAgentRuntimeInlineTools, resolveSingleProjectAgentRuntimeAgentId, runWithProjectAgentRuntime, } from "./agent-runtime.ts"; @@ -592,12 +593,30 @@ async function assertMultiAgentProjectDiscoveryWithoutServiceEntrypoint(): Promi resolve(agentsDir, fileName), [ 'import { agent } from "veryfront/agent";', + ...(id === "reviewer" + ? [ + 'import { tool } from "veryfront/tool";', + 'import { defineSchema } from "veryfront/schemas";', + ] + : []), "", "export default agent({", ` id: "${id}",`, ` name: "${name}",`, ' model: "openai/gpt-5.4",', ` system: "You are the ${name.toLowerCase()} agent.",`, + ...(id === "reviewer" + ? [ + " tools: {", + " inline_lookup: tool({", + ' id: "inline_lookup",', + ` description: "${id} inline lookup",`, + " inputSchema: defineSchema((v) => v.object({ value: v.string() }))(),", + " execute: ({ value }) => ({ value }),", + " }),", + " },", + ] + : []), "});", "", ].join("\n"), @@ -636,6 +655,11 @@ async function assertMultiAgentProjectDiscoveryWithoutServiceEntrypoint(): Promi assertEquals([...result.agents.keys()].sort(), ["reviewer", "support"]); assertEquals([...result.tools.keys()].sort(), ["echo", "lookup"]); + assertEquals( + getProjectAgentRuntimeInlineTools(result, "reviewer").get("inline_lookup")?.description, + "reviewer inline lookup", + ); + assertEquals(getProjectAgentRuntimeInlineTools(result, "support").size, 0); assertEquals(getProjectAgentRuntimeAgentIdCandidates(result), { codeAgentIds: ["reviewer", "support"], markdownAgentIds: [], diff --git a/src/agent/project/agent-runtime.ts b/src/agent/project/agent-runtime.ts index 39f889c01f..81f290c0fb 100644 --- a/src/agent/project/agent-runtime.ts +++ b/src/agent/project/agent-runtime.ts @@ -19,6 +19,7 @@ import { isRuntimeAgentMarkdownAgent, } from "../runtime/agent-markdown-adapter.ts"; import type { Agent, AgentConfig } from "../types.ts"; +import type { Tool } from "#veryfront/tool/types.ts"; import type { AgentSystem } from "#veryfront/agent/types.ts"; import { flattenSystemInstructions } from "#veryfront/agent/runtime/tool-inventory.ts"; import { @@ -166,6 +167,25 @@ function clearProjectAgentRuntimePrimitiveRegistries(): void { workflowRegistry.clear(); } +/** Return inline tools for one selected project agent without sharing names across agents. */ +export function getProjectAgentRuntimeInlineTools( + result: Pick, + agentId: string, +): Map { + const tools = new Map(); + const configuredTools = result.agents.get(agentId)?.config.tools; + if (configuredTools === undefined || configuredTools === true) return tools; + for (const [name, value] of objectEntries(configuredTools)) { + if ( + value !== false && value !== undefined && typeof value === "object" && + typeof (value as Tool).execute === "function" + ) { + tools.set(name, value as Tool); + } + } + return tools; +} + /** Discover project agent runtime helper. */ export async function discoverProjectAgentRuntime( input: DiscoverProjectAgentRuntimeInput, @@ -198,12 +218,13 @@ export async function discoverProjectAgentRuntime( // that publishes its replacement. Concurrent readers therefore see // either complete generation, never an empty or partially updated one. clearProjectAgentRuntimePrimitiveRegistries(); - return await replaceDiscoveredProjectPrimitives(discoveryOptions, { + const discovery = await replaceDiscoveredProjectPrimitives(discoveryOptions, { // Preserve the one-shot runtime contract: callers receive every // discovery error alongside the valid primitives and decide // whether those errors are fatal. Publication is still atomic. errorPolicy: "publish-valid", }); + return discovery; }); return { ...discovery, sourceIntegrationPolicy }; }, From 3a5a9814d4f4cf93de12cf57119b9a8138d625a1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 21:42:05 +0200 Subject: [PATCH 18/19] fix(agent): build isolated project catalogs under source policy --- docs/guides/agent-service-runtime.md | 4 + .../hosted/executor-project-runtime.test.ts | 103 ++++++++++++++++++ src/agent/hosted/executor-project-runtime.ts | 34 +++--- src/agent/hosted/executor-project-tools.ts | 3 +- src/agent/project/agent-runtime.ts | 12 +- 5 files changed, 137 insertions(+), 19 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index b8f268a5de..c0b5951385 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -500,6 +500,10 @@ An explicitly enabled project source can receive the current call's `activeSkill and bounded `activeSkillToolAvailability`. Omitted skill fields clear prior values. Credentials and other caller context fields do not cross the project channel. Unknown startup `mode` values fail before bootstrap configuration or artifact access. +Selected inline tools and discovered tools are combined under the exact project +source policy, including metadata access and later execution. Framework-generated +agent runtime tools are excluded from the project-only catalog, even when their +names appear in a grant. ### Broker composition diff --git a/src/agent/hosted/executor-project-runtime.test.ts b/src/agent/hosted/executor-project-runtime.test.ts index e4261b487b..6a1169c224 100644 --- a/src/agent/hosted/executor-project-runtime.test.ts +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -5,6 +5,7 @@ import { defineSchema } from "#veryfront/schemas/index.ts"; import { getActiveSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; import { tool } from "#veryfront/tool/factory.ts"; +import { markRuntimeLocalTool } from "#veryfront/agent/runtime/local-tool.ts"; import { agent } from "../factory.ts"; import type { ProjectAgentRuntimeDiscovery } from "../project/agent-runtime.ts"; import { createExecutorDiscovery } from "./executor-discovery.ts"; @@ -102,6 +103,9 @@ function fixture( }); return { discovery, + runtime, + registered, + coder, lifetime, started: started.promise, get loads() { @@ -117,6 +121,105 @@ function fixture( } describe("installed project tool runtime", () => { + it("constructs project tool metadata under the admitted source policy", async () => { + const denyAll = { schemaVersion: 1 as const, mode: "allowlist" as const, integrations: {} }; + const observed: unknown[] = []; + const f = fixture(undefined, () => { + Object.defineProperty(f.registered, "description", { + get() { + observed.push(getActiveSourceIntegrationPolicy()); + return "Inspect under policy"; + }, + }); + }, denyAll); + const owner = await createExecutorProjectToolRuntime({ + input: install(), + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + try { + assert(observed.length > 0); + for (const policy of observed) assertEquals(policy, denyAll); + } finally { + await owner.close(); + } + }); + + it("copies runtime catalogs without consulting replaceable map methods", async () => { + const f = fixture(undefined, () => { + const forbidden = () => { + throw new Error("Replaceable map operation reached"); + }; + Object.defineProperties(f.runtime.tools, { + [Symbol.iterator]: { value: forbidden }, + set: { value: forbidden }, + }); + Object.defineProperty(f.runtime.agents, "get", { value: forbidden }); + }); + const owner = await createExecutorProjectToolRuntime({ + input: install(), + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + try { + const execute = owner.operations.get("tool.execute"); + assert(execute?.mode === "stream"); + const frames = await Array.fromAsync(execute.handle({ + sourceId: "project", + toolName: "inspect", + toolCallId: "call", + args: { query: "approved" }, + }, { binding, signal: f.lifetime.signal, deadline: Date.now() + 10_000 })); + assert(frames[0] && typeof frames[0] === "object" && !Array.isArray(frames[0])); + assertEquals(frames[0].type, "result"); + assertEquals(f.calls, 1); + } finally { + await owner.close(); + } + }); + + it("excludes runtime-generated tools even when their names appear in the project grant", async () => { + let delegated = 0; + const local = markRuntimeLocalTool(tool({ + id: "generated-delegate", + description: "Runtime-only delegate", + inputSchema: defineSchema((v) => v.object({}))(), + execute: async () => { + delegated++; + return null; + }, + })); + const f = fixture(); + f.coder.config.tools = { inspect: f.registered, "generated-delegate": local }; + f.runtime.tools.set("generated-delegate", local); + const input = install(); + input.allowedToolNames.push("generated-delegate"); + const owner = await createExecutorProjectToolRuntime({ + input, + discovery: f.discovery, + signal: f.lifetime.signal, + deadline: Date.now() + 10_000, + }); + try { + const execute = owner.operations.get("tool.execute"); + assert(execute?.mode === "stream"); + assertEquals( + await Array.fromAsync(execute.handle({ + sourceId: "project", + toolName: "generated-delegate", + toolCallId: "call", + args: {}, + }, { binding, signal: f.lifetime.signal, deadline: Date.now() + 10_000 })), + [{ type: "failure" }], + ); + assertEquals(delegated, 0); + } finally { + await owner.close(); + } + }); + it("keeps original cleanup when discovery replaces the public close callback", async () => { const input = install(); input.context.agentId = "missing"; diff --git a/src/agent/hosted/executor-project-runtime.ts b/src/agent/hosted/executor-project-runtime.ts index 714ce241ec..cf41999ade 100644 --- a/src/agent/hosted/executor-project-runtime.ts +++ b/src/agent/hosted/executor-project-runtime.ts @@ -6,8 +6,10 @@ import { resolvePrivatePromise, } from "#veryfront/security/private-promise.ts"; import type { ExecutorOperation } from "../executor/channel.ts"; -import { runWithProjectAgentRuntime } from "../project/agent-runtime.ts"; -import { getProjectAgentRuntimeInlineTools } from "../project/agent-runtime.ts"; +import { + getProjectAgentRuntimeInlineTools, + runWithProjectAgentRuntime, +} from "#veryfront/agent/project/agent-runtime.ts"; import type { ExecutorDiscovery } from "./executor-discovery.ts"; import { getExecutorAgentDescribeResultSchema, @@ -80,19 +82,21 @@ export async function createExecutorProjectToolRuntime(options: { throw new Error("Project discovery did not match installation"); } const runtime: ReturnType = apply(getRuntime, discovery, []); - const runtimeTools = new Map(runtime.tools); - for (const [name, tool] of getProjectAgentRuntimeInlineTools(runtime, context.agentId)) { - runtimeTools.set(name, tool); - } - const tools = createExecutorProjectToolOperations({ - scope: { binding, signal, assertActive: () => signal.throwIfAborted() }, - context, - tools: runtimeTools, - runWithProjectRuntime: (fn) => runWithProjectAgentRuntime(runtime, fn), - allowedToolNames, - maxCalls, - maxConcurrent, - limits, + const tools = runWithProjectAgentRuntime(runtime, () => { + const runtimeTools = copyPrivateMap(runtime.tools, limits.maxTotalTools); + for (const [name, tool] of getProjectAgentRuntimeInlineTools(runtime, context.agentId)) { + runtimeTools.set(name, tool); + } + return createExecutorProjectToolOperations({ + scope: { binding, signal, assertActive: () => signal.throwIfAborted() }, + context, + tools: runtimeTools, + runWithProjectRuntime: (fn) => runWithProjectAgentRuntime(runtime, fn), + allowedToolNames, + maxCalls, + maxConcurrent, + limits, + }); }); const operations = createPrivateMap(); for (const [name, operation] of discoveryOperations) operations.set(name, operation); diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index 900caf8d82..32a4d4779b 100644 --- a/src/agent/hosted/executor-project-tools.ts +++ b/src/agent/hosted/executor-project-tools.ts @@ -18,6 +18,7 @@ import type { RemoteToolSource, Tool, ToolExecutionContext } from "#veryfront/to import { isToolVisibleTo } from "#veryfront/tool/executor.ts"; import { toolToProviderDefinition } from "#veryfront/tool/registry.ts"; import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; +import { isRuntimeLocalTool } from "#veryfront/agent/runtime/local-tool.ts"; import { createExecutorToolBroker, type ExecutorToolCapability, @@ -149,7 +150,7 @@ export function createExecutorProjectToolOperations( const aliases: { name: string; shortName: string }[] = []; for (const [name, registered] of tools) { if ( - !allowed.has(name) || isSkillInfrastructureToolId(name) || + !allowed.has(name) || isSkillInfrastructureToolId(name) || isRuntimeLocalTool(registered) || !isToolVisibleTo(registered, { agentId: fixed.agentId }) ) continue; if (typeof registered.execute !== "function") throw new TypeError("Invalid project tool"); diff --git a/src/agent/project/agent-runtime.ts b/src/agent/project/agent-runtime.ts index 81f290c0fb..d9f396aa5e 100644 --- a/src/agent/project/agent-runtime.ts +++ b/src/agent/project/agent-runtime.ts @@ -33,11 +33,13 @@ import { import { CONFIG_INVALID } from "#veryfront/errors"; import { compareStrings } from "#veryfront/utils/compare.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; +import { createPrivateMap } from "#veryfront/security/private-map.ts"; const objectSetPrototypeOf = Object.setPrototypeOf; const objectEntries = Object.entries; const arraySort = Array.prototype.sort; const apply = Reflect.apply; +const mapGet = Map.prototype.get; function selectedConfigToolNames( tools: Exclude, @@ -172,10 +174,14 @@ export function getProjectAgentRuntimeInlineTools( result: Pick, agentId: string, ): Map { - const tools = new Map(); - const configuredTools = result.agents.get(agentId)?.config.tools; + const tools = createPrivateMap(); + const selected: Agent | undefined = apply(mapGet, result.agents, [agentId]); + const configuredTools = selected?.config.tools; if (configuredTools === undefined || configuredTools === true) return tools; - for (const [name, value] of objectEntries(configuredTools)) { + const entries = objectEntries(configuredTools); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + const name = entry[0], value = entry[1]; if ( value !== false && value !== undefined && typeof value === "object" && typeof (value as Tool).execute === "function" From d816943fad569cb91ed1a8c6c9e8fcdfcca2a413 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 10 Sep 2026 21:49:12 +0200 Subject: [PATCH 19/19] fix(agent): scope full runtime inline tools to project policy --- docs/guides/agent-service-runtime.md | 2 ++ .../hosted/executor-runtime-prepare.test.ts | 31 +++++++++++++++++ src/agent/hosted/executor-runtime-prepare.ts | 33 +++++++++++-------- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index c0b5951385..f3ee4d320c 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -504,6 +504,8 @@ Selected inline tools and discovered tools are combined under the exact project source policy, including metadata access and later execution. Framework-generated agent runtime tools are excluded from the project-only catalog, even when their names appear in a grant. +The full-runtime profile applies the same source-policy scope while extracting +inline tools and reading their metadata during preparation. ### Broker composition diff --git a/src/agent/hosted/executor-runtime-prepare.test.ts b/src/agent/hosted/executor-runtime-prepare.test.ts index 47fe74724d..3271bc67c3 100644 --- a/src/agent/hosted/executor-runtime-prepare.test.ts +++ b/src/agent/hosted/executor-runtime-prepare.test.ts @@ -18,6 +18,8 @@ import { registerModelRuntimeResolverRevoker } from "#veryfront/agent/runtime/mo import { createExecutorModelAdmission } from "#veryfront/agent/hosted/executor-model-grant.ts"; import { assertPersistedModelOptions } from "./executor-model-dispatch-options.ts"; import { agent } from "#veryfront/agent/factory.ts"; +import { tool } from "#veryfront/tool/factory.ts"; +import { getActiveSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import type { ProjectAgentRuntimeDiscovery } from "#veryfront/agent/project/agent-runtime.ts"; import { createExecutorDiscovery } from "./executor-discovery.ts"; import { @@ -164,6 +166,35 @@ async function prepare( } describe("executor runtime preparation", () => { + it("extracts inline tools under the source policy in the full-runtime profile", async () => { + const policy = { schemaVersion: 1 as const, mode: "allowlist" as const, integrations: {} }; + const observed: unknown[] = []; + const registered = tool({ + id: "inspect", + description: "Inspect inline scope", + inputSchema: defineSchema((v) => v.object({}))(), + execute: async () => null, + }); + const execute = registered.execute; + const discovered = runtime({ tools: { inspect: registered } }); + discovered.sourceIntegrationPolicy = policy; + Object.defineProperty(registered, "execute", { + get() { + observed.push(getActiveSourceIntegrationPolicy()); + return execute; + }, + }); + const f = fixture({ load: () => Promise.resolve(discovered) }); + try { + const result = await prepare(f.owner); + assert(result && typeof result === "object" && !Array.isArray(result) && result.ok === true); + assert(observed.length > 0); + for (const active of observed) assertEquals(active, policy); + } finally { + await f.owner.close(); + } + }); + for ( const request of [ { thinking: { enabled: true, budgetTokens: 8192 } }, diff --git a/src/agent/hosted/executor-runtime-prepare.ts b/src/agent/hosted/executor-runtime-prepare.ts index 3b0d5818b9..df027725c8 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -11,6 +11,7 @@ import { import { isToolVisibleTo } from "#veryfront/tool/executor.ts"; import { isSkillInfrastructureToolId } from "#veryfront/skill/types.ts"; import { filterPrivateArray } from "#veryfront/security/private-array.ts"; +import { copyPrivateMap } from "#veryfront/security/private-map.ts"; import { chainPrivatePromise, resolvePrivatePromise } from "#veryfront/security/private-promise.ts"; import { createRuntimePreparationCore, @@ -25,6 +26,7 @@ export type { const mapGet = Map.prototype.get; const apply = Reflect.apply; +const fromEntries = Object.fromEntries; interface Options { binding: ExecutorBinding; @@ -55,20 +57,23 @@ export function createExecutorRuntimePreparation(input: Options) { () => operation.handle({ agentId }, context), ); runtime = discovery.getRuntime(); - const localTools = new Map(runtime.tools); - for (const [name, tool] of getProjectAgentRuntimeInlineTools(runtime, agentId)) { - localTools.set(name, tool); - } - return { - __proto__: null, - description, - localTools: Object.fromEntries(filterPrivateArray( - [...localTools], - ([id, value]) => - !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId }), - )), - sourceIntegrationPolicy: runtime.sourceIntegrationPolicy, - }; + const selectedRuntime = runtime; + return runWithProjectAgentRuntime(selectedRuntime, () => { + const localTools = copyPrivateMap(selectedRuntime.tools); + for (const [name, tool] of getProjectAgentRuntimeInlineTools(selectedRuntime, agentId)) { + localTools.set(name, tool); + } + return { + __proto__: null, + description, + localTools: fromEntries(filterPrivateArray( + [...localTools], + ([id, value]) => + !isSkillInfrastructureToolId(id) && isToolVisibleTo(value, { agentId }), + )), + sourceIntegrationPolicy: selectedRuntime.sourceIntegrationPolicy, + }; + }); }, instantiate: (options, runtimeOptions) => { if (!runtime) throw new ExecutorRuntimePreparationError("EXECUTOR_RUNTIME_NOT_PREPARED");