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..f3ee4d320c 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -445,11 +445,69 @@ 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. + +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. +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 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-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..6a1169c224 --- /dev/null +++ b/src/agent/hosted/executor-project-runtime.test.ts @@ -0,0 +1,414 @@ +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 { 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"; +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, + onLoad?: () => void, + sourceIntegrationPolicy: SourceIntegrationPolicyManifest = { + schemaVersion: 1 as const, + mode: "unrestricted" as const, + }, + onExecute?: () => unknown, +) { + 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; + const executed = onExecute?.(); + return { + query: args.query, + agentId: context?.agentId, + projectId: context?.projectId, + runId: context?.runId, + ...(context?.userId === undefined ? {} : { userId: context.userId }), + ...(context?.projectSlug === undefined ? {} : { projectSlug: context.projectSlug }), + ...(executed === undefined ? {} : { executed }), + }; + }, + }); + 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, + }; + const discovery = createExecutorDiscovery({ + binding, + source, + signal: lifetime.signal, + projectDir: "/synthetic-project", + backend: { + load: () => { + loads++; + onLoad?.(); + return Promise.resolve(runtime); + }, + cleanup: () => { + cleaned++; + return Promise.resolve(); + }, + }, + }); + return { + discovery, + runtime, + registered, + coder, + lifetime, + started: started.promise, + get loads() { + return loads; + }, + get cleaned() { + return cleaned; + }, + get calls() { + return calls; + }, + }; +} + +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"; + 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(); + 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; + }); + 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", + userId: "synthetic-user", + projectSlug: "synthetic-slug", + }, + }]); + } 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({ + 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); + }); + + 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 new file mode 100644 index 0000000000..cf41999ade --- /dev/null +++ b/src/agent/hosted/executor-project-runtime.ts @@ -0,0 +1,127 @@ +import { copyPrivateMap, createPrivateMap } from "#veryfront/security/private-map.ts"; +import { createPrivateSet } from "#veryfront/security/private-set.ts"; +import { + chainPrivatePromise, + createPrivateDeferred, + resolvePrivatePromise, +} from "#veryfront/security/private-promise.ts"; +import type { ExecutorOperation } from "../executor/channel.ts"; +import { + getProjectAgentRuntimeInlineTools, + runWithProjectAgentRuntime, +} from "#veryfront/agent/project/agent-runtime.ts"; +import type { ExecutorDiscovery } from "./executor-discovery.ts"; +import { + getExecutorAgentDescribeResultSchema, + parseDiscoveryData, +} from "./executor-discovery-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; + discovery: ExecutorDiscovery; + signal: AbortSignal; + deadline: number; +}): Promise { + 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, + ...(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); + 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 = discoveryOperations.get("agent.describe"); + if (describe?.mode !== "unary") throw new Error("Project discovery unavailable"); + const result = parseDiscoveryData( + getExecutorAgentDescribeResultSchema(), + await chainPrivatePromise(resolvePrivatePromise(), () => + describe.handle( + { agentId: context.agentId }, + { binding, signal, deadline }, + )), + true, + ); + signal.throwIfAborted(); + if ( + !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 = 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); + 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(); + apply(retainRuntimeTask, discovery, [retained.promise]); + try { + yield* operation.handle(value, context); + } finally { + retained.resolve(); + } + }, + });} + } + return { + operations, + close: () => apply(close, discovery, []), + settled, + }; + } catch (error) { + await apply(close, discovery, []); + throw error; + } +} diff --git a/src/agent/hosted/executor-project-tools.ts b/src/agent/hosted/executor-project-tools.ts index b895d82e88..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, @@ -93,6 +94,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; @@ -147,13 +150,17 @@ 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"); 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, 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 de4fdbd935..c83167a09f 100644 --- a/src/agent/hosted/executor-runtime-entrypoint.ts +++ b/src/agent/hosted/executor-runtime-entrypoint.ts @@ -56,13 +56,21 @@ 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 & { + /** 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 }>; } = {}, ) { + 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 ( @@ -77,48 +85,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 = 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..4216d08c62 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,33 @@ 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(), + userId: getExecutorDiscoveryIdSchema().optional(), + projectSlug: getExecutorDiscoveryIdSchema().optional(), + }).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 093d620a6e..32ed9d2138 100644 --- a/src/agent/hosted/executor-runtime-install.ts +++ b/src/agent/hosted/executor-runtime-install.ts @@ -12,8 +12,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"; @@ -24,32 +26,86 @@ 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 operationEntries = Object.entries(operationModes); const apply = Reflect.apply; +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 operationEntries = Object.entries(operationModes); + const prepareInstallation = (() => { + if (options.mode === "project-tools") { + const install = options.install; + return (value: unknown, context: ExecutorOperationContext) => { + const input = parseExecutorInstallation(getExecutorProjectToolInstallSchema(), value); + return { + input, + start: (signal: AbortSignal): Promise => + apply(install, options, [input, signal, context]), + }; + }; + } + if (options.mode !== undefined && options.mode !== "runtime") { + throw new TypeError("Invalid executor installation profile"); + } + const install = options.install; + return (value: unknown, context: ExecutorOperationContext) => { + const input = parseExecutorInstallation(getExecutorRuntimeInstallSchema(), value); + return { + input, + start: (signal: AbortSignal): Promise => + apply(install, options, [input, signal, context]), + }; + }; + })(); const binding = parseExecutorInstallation(getExecutorBindingSchema(), options.binding); const artifact = parseExecutorInstallation(getExecutorArtifactManifestSchema(), options.artifact); const lifetime = new AbortController(); 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; @@ -124,7 +180,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 || @@ -132,13 +188,10 @@ export function createExecutorRuntimeInstallation(options: { ) throw new Error("Executor installation not granted"); phase = "installing"; context.signal.addEventListener("abort", abort, { once: true }); - setup = chainPrivatePromise( - resolvePrivatePromise(), - (): Promise => { - assertActive(context); - return apply(install, options, [input, lifetime.signal]); - }, - ); + setup = chainPrivatePromise(resolvePrivatePromise(), () => { + assertActive(context); + return start(lifetime.signal); + }); try { const loaded = await setup; assertActive(context); 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 17f224c92a..df027725c8 100644 --- a/src/agent/hosted/executor-runtime-prepare.ts +++ b/src/agent/hosted/executor-runtime-prepare.ts @@ -4,12 +4,14 @@ 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"; 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, @@ -24,6 +26,7 @@ export type { const mapGet = Map.prototype.get; const apply = Reflect.apply; +const fromEntries = Object.fromEntries; interface Options { binding: ExecutorBinding; @@ -54,16 +57,23 @@ export function createExecutorRuntimePreparation(input: Options) { () => operation.handle({ agentId }, context), ); runtime = discovery.getRuntime(); - return { - __proto__: null, - description, - localTools: Object.fromEntries(filterPrivateArray( - [...runtime.tools], - ([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"); 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..d9f396aa5e 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 { @@ -32,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, @@ -166,6 +169,29 @@ 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 = createPrivateMap(); + const selected: Agent | undefined = apply(mapGet, result.agents, [agentId]); + const configuredTools = selected?.config.tools; + if (configuredTools === undefined || configuredTools === true) return tools; + 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" + ) { + tools.set(name, value as Tool); + } + } + return tools; +} + /** Discover project agent runtime helper. */ export async function discoverProjectAgentRuntime( input: DiscoverProjectAgentRuntimeInput, @@ -198,12 +224,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 }; }, 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) { 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..b8002e60ff 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,197 @@ 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); + }); + 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, + ); }); - 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: { + 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: { + agentId: projectContext.agentId, + projectId: projectContext.projectId, + execution: { kind: "canonical", runId: projectContext.runId }, + }, + 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 }); + } + }); + } }